mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 20:37:39 +00:00
Merge branch 'Wei-Shaw:main' into main
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# fork add
|
||||||
|
docs/
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
205
README.md
205
README.md
@@ -389,11 +389,20 @@ docker-compose.yml 已包含:
|
|||||||
|
|
||||||
**Claude Code 设置环境变量:**
|
**Claude Code 设置环境变量:**
|
||||||
|
|
||||||
|
默认使用标准 Claude 账号池:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果后台添加了 Droid 类型账号池,请将基础地址改为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/droid/claude" # 根据实际情况替换域名/IP
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
|
```
|
||||||
|
|
||||||
**VSCode Claude 插件配置:**
|
**VSCode Claude 插件配置:**
|
||||||
|
|
||||||
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置:
|
||||||
@@ -445,6 +454,8 @@ requires_openai_auth = true
|
|||||||
env_key = "CRS_OAI_KEY"
|
env_key = "CRS_OAI_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如需通过 Droid 类型账号池访问 Codex CLI,只需将 `base_url` 改为 `http://127.0.0.1:3000/droid/openai`(其余配置保持不变)。
|
||||||
|
|
||||||
在 `~/.codex/auth.json` 文件中配置API密钥为 null:
|
在 `~/.codex/auth.json` 文件中配置API密钥为 null:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -461,6 +472,35 @@ export CRS_OAI_KEY="后台创建的API密钥"
|
|||||||
|
|
||||||
> ⚠️ 在通过 Nginx 反向代理 CRS 服务并使用 Codex CLI 时,需要在 http 块中添加 underscores_in_headers on;。因为 Nginx 默认会移除带下划线的请求头(如 session_id),一旦该头被丢弃,多账号环境下的粘性会话功能将失效。
|
> ⚠️ 在通过 Nginx 反向代理 CRS 服务并使用 Codex CLI 时,需要在 http 块中添加 underscores_in_headers on;。因为 Nginx 默认会移除带下划线的请求头(如 session_id),一旦该头被丢弃,多账号环境下的粘性会话功能将失效。
|
||||||
|
|
||||||
|
**Droid CLI 配置:**
|
||||||
|
|
||||||
|
Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义模型以指向本服务的新端点:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"custom_models": [
|
||||||
|
{
|
||||||
|
"model_display_name": "Sonnet 4.5 [Custom]",
|
||||||
|
"model": "claude-sonnet-4-5-20250929",
|
||||||
|
"base_url": "http://127.0.0.1:3000/droid/claude",
|
||||||
|
"api_key": "后台创建的API密钥",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"max_tokens": 8192
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_display_name": "GPT5-Codex [Custom]",
|
||||||
|
"model": "gpt-5-codex",
|
||||||
|
"base_url": "http://127.0.0.1:3000/droid/openai",
|
||||||
|
"api_key": "后台创建的API密钥",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 16384
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 将示例中的 `http://127.0.0.1:3000` 替换为你的服务域名或公网地址,并写入后台生成的 API 密钥(cr_ 开头)。
|
||||||
|
|
||||||
### 5. 第三方工具API接入
|
### 5. 第三方工具API接入
|
||||||
|
|
||||||
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。
|
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。
|
||||||
@@ -515,6 +555,23 @@ gpt-5 # Codex使用固定模型ID
|
|||||||
- API地址填入:`http://你的服务器:3000/openai`
|
- API地址填入:`http://你的服务器:3000/openai`
|
||||||
- API Key填入:后台创建的API密钥(cr_开头)
|
- API Key填入:后台创建的API密钥(cr_开头)
|
||||||
- **重要**:Codex只支持Openai-Response标准
|
- **重要**:Codex只支持Openai-Response标准
|
||||||
|
- 💡 如果希望在 Cherry Studio 中使用 Droid 类型账号,请改填 `http://你的服务器:3000/droid/openai`,并保持其他设置不变。
|
||||||
|
|
||||||
|
**4. Droid账号接入:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# Claude Code / Droid CLI 使用的 API 地址
|
||||||
|
http://你的服务器:3000/droid/claude
|
||||||
|
|
||||||
|
# Codex CLI 使用的 API 地址
|
||||||
|
http://你的服务器:3000/droid/openai
|
||||||
|
```
|
||||||
|
|
||||||
|
配置步骤:
|
||||||
|
- 供应商类型选择"Anthropic"或"Openai-Response"(根据模型类型)
|
||||||
|
- API地址填入:`http://你的服务器:3000/droid/claude` 或 `http://你的服务器:3000/droid/openai`
|
||||||
|
- API Key填入:后台创建的API密钥(cr_开头)
|
||||||
|
- 建议自定义模型名称以区分 Droid 账号池
|
||||||
|
|
||||||
**Cherry Studio 地址格式重要说明:**
|
**Cherry Studio 地址格式重要说明:**
|
||||||
|
|
||||||
@@ -530,8 +587,10 @@ gpt-5 # Codex使用固定模型ID
|
|||||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||||
- 根据不同的路由前缀自动识别账号类型
|
- 根据不同的路由前缀自动识别账号类型
|
||||||
- `/claude/` - 使用Claude账号池
|
- `/claude/` - 使用Claude账号池
|
||||||
|
- `/droid/claude/` - 使用Droid类型Claude账号池(服务于 Claude Code / Droid CLI)
|
||||||
- `/gemini/` - 使用Gemini账号池
|
- `/gemini/` - 使用Gemini账号池
|
||||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||||
|
- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(服务于 Codex CLI)
|
||||||
- 支持所有标准API端点(messages、models等)
|
- 支持所有标准API端点(messages、models等)
|
||||||
|
|
||||||
**重要说明:**
|
**重要说明:**
|
||||||
@@ -670,13 +729,17 @@ redis-cli ping
|
|||||||
|
|
||||||
## 🛠️ 进阶
|
## 🛠️ 进阶
|
||||||
|
|
||||||
### 生产环境部署建议(重要!)
|
### 反向代理部署指南
|
||||||
|
|
||||||
**强烈建议使用Caddy反向代理(自动HTTPS)**
|
在生产环境中,建议通过反向代理进行连接,以便使用自动 HTTPS、安全头部和性能优化。下面提供两种常用方案: **Caddy** 和 **Nginx Proxy Manager (NPM)**。
|
||||||
|
|
||||||
建议使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单:
|
---
|
||||||
|
|
||||||
**1. 安装Caddy**
|
## Caddy 方案
|
||||||
|
|
||||||
|
Caddy 是一款自动管理 HTTPS 证书的 Web 服务器,配置简单、性能优秀,很适合不需要 Docker 环境的部署方案。
|
||||||
|
|
||||||
|
**1. 安装 Caddy**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ubuntu/Debian
|
# Ubuntu/Debian
|
||||||
@@ -692,23 +755,23 @@ sudo yum copr enable @caddy/caddy
|
|||||||
sudo yum install caddy
|
sudo yum install caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Caddy配置(超简单!)**
|
**2. Caddy 配置**
|
||||||
|
|
||||||
编辑 `/etc/caddy/Caddyfile`:
|
编辑 `/etc/caddy/Caddyfile` :
|
||||||
|
|
||||||
```
|
```caddy
|
||||||
your-domain.com {
|
your-domain.com {
|
||||||
# 反向代理到本地服务
|
# 反向代理到本地服务
|
||||||
reverse_proxy 127.0.0.1:3000 {
|
reverse_proxy 127.0.0.1:3000 {
|
||||||
# 支持流式响应(SSE)
|
# 支持流式响应或 SSE
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
|
|
||||||
# 传递真实IP
|
# 传递真实 IP
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
|
||||||
# 超时设置(适合长连接)
|
# 长读/写超时配置
|
||||||
transport http {
|
transport http {
|
||||||
read_timeout 300s
|
read_timeout 300s
|
||||||
write_timeout 300s
|
write_timeout 300s
|
||||||
@@ -726,42 +789,132 @@ your-domain.com {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. 启动Caddy**
|
**3. 启动 Caddy**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试配置
|
|
||||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
sudo systemctl start caddy
|
sudo systemctl start caddy
|
||||||
sudo systemctl enable caddy
|
sudo systemctl enable caddy
|
||||||
|
|
||||||
# 查看状态
|
|
||||||
sudo systemctl status caddy
|
sudo systemctl status caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
**4. 更新服务配置**
|
**4. 服务配置**
|
||||||
|
|
||||||
修改你的服务配置,让它只监听本地:
|
Caddy 会自动管理 HTTPS,因此可以将服务限制在本地进行监听:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// config/config.js
|
// config/config.js
|
||||||
module.exports = {
|
module.exports = {
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '127.0.0.1' // 只监听本地,通过nginx代理
|
host: '127.0.0.1' // 只监听本地
|
||||||
}
|
}
|
||||||
// ... 其他配置
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Caddy优势:**
|
**Caddy 特点**
|
||||||
|
|
||||||
- 🔒 **自动HTTPS**: 自动申请和续期Let's Encrypt证书,零配置
|
* 🔒 自动 HTTPS,零配置证书管理
|
||||||
- 🛡️ **安全默认**: 默认启用现代安全协议和加密套件
|
* 🛡️ 安全默认配置,启用现代 TLS 套件
|
||||||
- 🚀 **流式支持**: 原生支持SSE/WebSocket等流式传输
|
* ⚡ HTTP/2 和流式传输支持
|
||||||
- 📊 **简单配置**: 配置文件极其简洁,易于维护
|
* 🔧 配置文件简洁,易于维护
|
||||||
- ⚡ **HTTP/2**: 默认启用HTTP/2,提升传输性能
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nginx Proxy Manager (NPM) 方案
|
||||||
|
|
||||||
|
Nginx Proxy Manager 通过图形化界面管理反向代理和 HTTPS 证书,並以 Docker 容器部署。
|
||||||
|
|
||||||
|
**1. 在 NPM 创建新的 Proxy Host**
|
||||||
|
|
||||||
|
Details 配置如下:
|
||||||
|
|
||||||
|
| 项目 | 设置 |
|
||||||
|
| --------------------- | ----------------------- |
|
||||||
|
| Domain Names | relay.example.com |
|
||||||
|
| Scheme | http |
|
||||||
|
| Forward Hostname / IP | 192.168.0.1 (docker 机器 IP) |
|
||||||
|
| Forward Port | 3000 |
|
||||||
|
| Block Common Exploits | ☑️ |
|
||||||
|
| Websockets Support | ❌ **关闭** |
|
||||||
|
| Cache Assets | ❌ **关闭** |
|
||||||
|
| Access List | Publicly Accessible |
|
||||||
|
|
||||||
|
> 注意:
|
||||||
|
> - 请确保 Claude Relay Service **监听 host 为 `0.0.0.0` 、容器 IP 或本机 IP**,以便 NPM 实现内网连接。
|
||||||
|
> - **Websockets Support 和 Cache Assets 必须关闭**,否则会导致 SSE / 流式响应失败。
|
||||||
|
|
||||||
|
**2. Custom locations**
|
||||||
|
|
||||||
|
無需添加任何内容,保持为空。
|
||||||
|
|
||||||
|
**3. SSL 设置**
|
||||||
|
|
||||||
|
* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) 或已有证书
|
||||||
|
* ☑️ **Force SSL**
|
||||||
|
* ☑️ **HTTP/2 Support**
|
||||||
|
* ☑️ **HSTS Enabled**
|
||||||
|
* ☑️ **HSTS Subdomains**
|
||||||
|
|
||||||
|
**4. Advanced 配置**
|
||||||
|
|
||||||
|
Custom Nginx Configuration 中添加以下内容:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# 传递真实用户 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;
|
||||||
|
|
||||||
|
# 支持 WebSocket / SSE 等流式通信
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
# 长连接 / 超时设置(适合 AI 聊天流式传输)
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
|
||||||
|
# ---- 安全性设置 ----
|
||||||
|
# 严格 HTTPS 策略 (HSTS)
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# 阻挡点击劫持与内容嗅探
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
|
# Referrer / Permissions 限制策略
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
# 隐藏服务器信息(等效于 Caddy 的 `-Server`)
|
||||||
|
proxy_hide_header Server;
|
||||||
|
|
||||||
|
# ---- 性能微调 ----
|
||||||
|
# 关闭代理端缓存,确保即时响应(SSE / Streaming)
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_no_cache $http_upgrade;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 启动和验证**
|
||||||
|
|
||||||
|
* 保存后等待 NPM 自动申请 Let's Encrypt 证书(如果有)。
|
||||||
|
* Dashboard 中查看 Proxy Host 状态,确保显示为 "Online"。
|
||||||
|
* 访问 `https://relay.example.com`,如果显示绿色锁图标即表示 HTTPS 正常。
|
||||||
|
|
||||||
|
**NPM 特点**
|
||||||
|
|
||||||
|
* 🔒 自动申请和续期证书
|
||||||
|
* 🔧 图形化界面,方便管理多服务
|
||||||
|
* ⚡ 原生支持 HTTP/2 / HTTPS
|
||||||
|
* 🚀 适合 Docker 容器部署
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
上述两种方案均可用于生产部署。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
145
README_EN.md
145
README_EN.md
@@ -327,13 +327,18 @@ redis-cli ping
|
|||||||
|
|
||||||
## 🛠️ Advanced Usage
|
## 🛠️ Advanced Usage
|
||||||
|
|
||||||
### Production Deployment Recommendations (Important!)
|
### Reverse Proxy Deployment Guide
|
||||||
|
|
||||||
**Strongly recommend using Caddy reverse proxy (Automatic HTTPS)**
|
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)**.
|
||||||
|
|
||||||
Recommend using Caddy as reverse proxy, it will automatically apply and renew SSL certificates with simpler configuration:
|
---
|
||||||
|
|
||||||
|
## Caddy Solution
|
||||||
|
|
||||||
|
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**
|
**1. Install Caddy**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ubuntu/Debian
|
# Ubuntu/Debian
|
||||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
|
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||||
@@ -348,14 +353,15 @@ sudo yum copr enable @caddy/caddy
|
|||||||
sudo yum install caddy
|
sudo yum install caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Caddy Configuration (Super Simple!)**
|
**2. Caddy Configuration**
|
||||||
|
|
||||||
Edit `/etc/caddy/Caddyfile`:
|
Edit `/etc/caddy/Caddyfile`:
|
||||||
```
|
|
||||||
|
```caddy
|
||||||
your-domain.com {
|
your-domain.com {
|
||||||
# Reverse proxy to local service
|
# Reverse proxy to local service
|
||||||
reverse_proxy 127.0.0.1:3000 {
|
reverse_proxy 127.0.0.1:3000 {
|
||||||
# Support streaming responses (SSE)
|
# Support streaming responses or SSE
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
|
|
||||||
# Pass real IP
|
# Pass real IP
|
||||||
@@ -363,7 +369,7 @@ your-domain.com {
|
|||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
|
||||||
# Timeout settings (suitable for long connections)
|
# Long read/write timeout configuration
|
||||||
transport http {
|
transport http {
|
||||||
read_timeout 300s
|
read_timeout 300s
|
||||||
write_timeout 300s
|
write_timeout 300s
|
||||||
@@ -382,38 +388,131 @@ your-domain.com {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**3. Start Caddy**
|
**3. Start Caddy**
|
||||||
```bash
|
|
||||||
# Test configuration
|
|
||||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
# Start service
|
```bash
|
||||||
|
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||||
sudo systemctl start caddy
|
sudo systemctl start caddy
|
||||||
sudo systemctl enable caddy
|
sudo systemctl enable caddy
|
||||||
|
|
||||||
# Check status
|
|
||||||
sudo systemctl status caddy
|
sudo systemctl status caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
**4. Update service configuration**
|
**4. Service Configuration**
|
||||||
|
|
||||||
|
Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only:
|
||||||
|
|
||||||
Modify your service configuration to listen only locally:
|
|
||||||
```javascript
|
```javascript
|
||||||
// config/config.js
|
// config/config.js
|
||||||
module.exports = {
|
module.exports = {
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '127.0.0.1' // Listen only locally, proxy through nginx
|
host: '127.0.0.1' // Listen locally only
|
||||||
}
|
}
|
||||||
// ... other configurations
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Caddy Advantages:**
|
**Caddy Features**
|
||||||
- 🔒 **Automatic HTTPS**: Automatically apply and renew Let's Encrypt certificates, zero configuration
|
|
||||||
- 🛡️ **Secure by Default**: Modern security protocols and cipher suites enabled by default
|
* 🔒 Automatic HTTPS with zero-configuration certificate management
|
||||||
- 🚀 **Streaming Support**: Native support for SSE/WebSocket streaming
|
* 🛡️ Secure default configuration with modern TLS suites
|
||||||
- 📊 **Simple Configuration**: Extremely concise configuration files, easy to maintain
|
* ⚡ HTTP/2 and streaming support
|
||||||
- ⚡ **HTTP/2**: HTTP/2 enabled by default for improved performance
|
* 🔧 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**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,10 @@
|
|||||||
"ora": "^5.4.1",
|
"ora": "^5.4.1",
|
||||||
"rate-limiter-flexible": "^5.0.5",
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
"socks-proxy-agent": "^8.0.2",
|
"socks-proxy-agent": "^8.0.2",
|
||||||
|
"string-similarity": "^4.0.4",
|
||||||
"table": "^6.8.1",
|
"table": "^6.8.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"string-similarity": "^4.0.4",
|
|
||||||
"winston-daily-rotate-file": "^4.7.1"
|
"winston-daily-rotate-file": "^4.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
|||||||
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
|
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
|
||||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||||||
const openaiRoutes = require('./routes/openaiRoutes')
|
const openaiRoutes = require('./routes/openaiRoutes')
|
||||||
|
const droidRoutes = require('./routes/droidRoutes')
|
||||||
const userRoutes = require('./routes/userRoutes')
|
const userRoutes = require('./routes/userRoutes')
|
||||||
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
|
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
|
||||||
const webhookRoutes = require('./routes/webhook')
|
const webhookRoutes = require('./routes/webhook')
|
||||||
@@ -262,6 +263,8 @@ class Application {
|
|||||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||||
this.app.use('/openai', openaiRoutes)
|
this.app.use('/openai', openaiRoutes)
|
||||||
|
// Droid 路由:支持多种 Factory.ai 端点
|
||||||
|
this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
|
||||||
this.app.use('/azure', azureOpenaiRoutes)
|
this.app.use('/azure', azureOpenaiRoutes)
|
||||||
this.app.use('/admin/webhook', webhookRoutes)
|
this.app.use('/admin/webhook', webhookRoutes)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,37 @@ const redis = require('../models/redis')
|
|||||||
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
||||||
const ClientValidator = require('../validators/clientValidator')
|
const ClientValidator = require('../validators/clientValidator')
|
||||||
|
|
||||||
|
const TOKEN_COUNT_PATHS = new Set([
|
||||||
|
'/v1/messages/count_tokens',
|
||||||
|
'/api/v1/messages/count_tokens',
|
||||||
|
'/claude/v1/messages/count_tokens',
|
||||||
|
'/droid/claude/v1/messages/count_tokens'
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeRequestPath(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
const lower = value.split('?')[0].toLowerCase()
|
||||||
|
const collapsed = lower.replace(/\/{2,}/g, '/')
|
||||||
|
if (collapsed.length > 1 && collapsed.endsWith('/')) {
|
||||||
|
return collapsed.slice(0, -1)
|
||||||
|
}
|
||||||
|
return collapsed || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenCountRequest(req) {
|
||||||
|
const combined = normalizeRequestPath(`${req.baseUrl || ''}${req.path || ''}`)
|
||||||
|
if (TOKEN_COUNT_PATHS.has(combined)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const original = normalizeRequestPath(req.originalUrl || '')
|
||||||
|
if (TOKEN_COUNT_PATHS.has(original)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 🔑 API Key验证中间件(优化版)
|
// 🔑 API Key验证中间件(优化版)
|
||||||
const authenticateApiKey = async (req, res, next) => {
|
const authenticateApiKey = async (req, res, next) => {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
@@ -49,8 +80,11 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skipKeyRestrictions = isTokenCountRequest(req)
|
||||||
|
|
||||||
// 🔒 检查客户端限制(使用新的验证器)
|
// 🔒 检查客户端限制(使用新的验证器)
|
||||||
if (
|
if (
|
||||||
|
!skipKeyRestrictions &&
|
||||||
validation.keyData.enableClientRestriction &&
|
validation.keyData.enableClientRestriction &&
|
||||||
validation.keyData.allowedClients?.length > 0
|
validation.keyData.allowedClients?.length > 0
|
||||||
) {
|
) {
|
||||||
@@ -81,7 +115,7 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
|
|
||||||
// 检查并发限制
|
// 检查并发限制
|
||||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
||||||
if (concurrencyLimit > 0) {
|
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
||||||
const concurrencyConfig = config.concurrency || {}
|
const concurrencyConfig = config.concurrency || {}
|
||||||
const leaseSeconds = Math.max(concurrencyConfig.leaseSeconds || 900, 30)
|
const leaseSeconds = Math.max(concurrencyConfig.leaseSeconds || 900, 30)
|
||||||
const rawRenewInterval =
|
const rawRenewInterval =
|
||||||
@@ -438,6 +472,7 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
geminiAccountId: validation.keyData.geminiAccountId,
|
geminiAccountId: validation.keyData.geminiAccountId,
|
||||||
openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID
|
openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID
|
||||||
bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
|
droidAccountId: validation.keyData.droidAccountId,
|
||||||
permissions: validation.keyData.permissions,
|
permissions: validation.keyData.permissions,
|
||||||
concurrencyLimit: validation.keyData.concurrencyLimit,
|
concurrencyLimit: validation.keyData.concurrencyLimit,
|
||||||
rateLimitWindow: validation.keyData.rateLimitWindow,
|
rateLimitWindow: validation.keyData.rateLimitWindow,
|
||||||
|
|||||||
@@ -858,7 +858,9 @@ class RedisClient {
|
|||||||
|
|
||||||
// 获取账户创建时间来计算平均值 - 支持不同类型的账号
|
// 获取账户创建时间来计算平均值 - 支持不同类型的账号
|
||||||
let accountData = {}
|
let accountData = {}
|
||||||
if (accountType === 'openai') {
|
if (accountType === 'droid') {
|
||||||
|
accountData = await this.client.hgetall(`droid:account:${accountId}`)
|
||||||
|
} else if (accountType === 'openai') {
|
||||||
accountData = await this.client.hgetall(`openai:account:${accountId}`)
|
accountData = await this.client.hgetall(`openai:account:${accountId}`)
|
||||||
} else if (accountType === 'openai-responses') {
|
} else if (accountType === 'openai-responses') {
|
||||||
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
|
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
|
||||||
@@ -874,6 +876,9 @@ class RedisClient {
|
|||||||
if (!accountData.createdAt) {
|
if (!accountData.createdAt) {
|
||||||
accountData = await this.client.hgetall(`openai_account:${accountId}`)
|
accountData = await this.client.hgetall(`openai_account:${accountId}`)
|
||||||
}
|
}
|
||||||
|
if (!accountData.createdAt) {
|
||||||
|
accountData = await this.client.hgetall(`droid:account:${accountId}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date()
|
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -1066,6 +1071,35 @@ class RedisClient {
|
|||||||
const key = `claude:account:${accountId}`
|
const key = `claude:account:${accountId}`
|
||||||
return await this.client.del(key)
|
return await this.client.del(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🤖 Droid 账户相关操作
|
||||||
|
async setDroidAccount(accountId, accountData) {
|
||||||
|
const key = `droid:account:${accountId}`
|
||||||
|
await this.client.hset(key, accountData)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDroidAccount(accountId) {
|
||||||
|
const key = `droid:account:${accountId}`
|
||||||
|
return await this.client.hgetall(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllDroidAccounts() {
|
||||||
|
const keys = await this.client.keys('droid:account:*')
|
||||||
|
const accounts = []
|
||||||
|
for (const key of keys) {
|
||||||
|
const accountData = await this.client.hgetall(key)
|
||||||
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
accounts.push({ id: key.replace('droid:account:', ''), ...accountData })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDroidAccount(accountId) {
|
||||||
|
const key = `droid:account:${accountId}`
|
||||||
|
return await this.client.del(key)
|
||||||
|
}
|
||||||
|
|
||||||
async setOpenAiAccount(accountId, accountData) {
|
async setOpenAiAccount(accountId, accountData) {
|
||||||
const key = `openai:account:${accountId}`
|
const key = `openai:account:${accountId}`
|
||||||
await this.client.hset(key, accountData)
|
await this.client.hset(key, accountData)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
|
|||||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||||
const ccrAccountService = require('../services/ccrAccountService')
|
const ccrAccountService = require('../services/ccrAccountService')
|
||||||
const geminiAccountService = require('../services/geminiAccountService')
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
|
const droidAccountService = require('../services/droidAccountService')
|
||||||
const openaiAccountService = require('../services/openaiAccountService')
|
const openaiAccountService = require('../services/openaiAccountService')
|
||||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||||
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
||||||
@@ -13,6 +14,11 @@ 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')
|
||||||
const oauthHelper = require('../utils/oauthHelper')
|
const oauthHelper = require('../utils/oauthHelper')
|
||||||
|
const {
|
||||||
|
startDeviceAuthorization,
|
||||||
|
pollDeviceAuthorization,
|
||||||
|
WorkOSDeviceAuthError
|
||||||
|
} = require('../utils/workosOAuthHelper')
|
||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
const pricingService = require('../services/pricingService')
|
const pricingService = require('../services/pricingService')
|
||||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||||
@@ -533,6 +539,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
geminiAccountId,
|
geminiAccountId,
|
||||||
openaiAccountId,
|
openaiAccountId,
|
||||||
bedrockAccountId,
|
bedrockAccountId,
|
||||||
|
droidAccountId,
|
||||||
permissions,
|
permissions,
|
||||||
concurrencyLimit,
|
concurrencyLimit,
|
||||||
rateLimitWindow,
|
rateLimitWindow,
|
||||||
@@ -670,6 +677,18 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证服务权限字段
|
||||||
|
if (
|
||||||
|
permissions !== undefined &&
|
||||||
|
permissions !== null &&
|
||||||
|
permissions !== '' &&
|
||||||
|
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const newKey = await apiKeyService.generateApiKey({
|
const newKey = await apiKeyService.generateApiKey({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -680,6 +699,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
geminiAccountId,
|
geminiAccountId,
|
||||||
openaiAccountId,
|
openaiAccountId,
|
||||||
bedrockAccountId,
|
bedrockAccountId,
|
||||||
|
droidAccountId,
|
||||||
permissions,
|
permissions,
|
||||||
concurrencyLimit,
|
concurrencyLimit,
|
||||||
rateLimitWindow,
|
rateLimitWindow,
|
||||||
@@ -721,6 +741,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
geminiAccountId,
|
geminiAccountId,
|
||||||
openaiAccountId,
|
openaiAccountId,
|
||||||
bedrockAccountId,
|
bedrockAccountId,
|
||||||
|
droidAccountId,
|
||||||
permissions,
|
permissions,
|
||||||
concurrencyLimit,
|
concurrencyLimit,
|
||||||
rateLimitWindow,
|
rateLimitWindow,
|
||||||
@@ -755,6 +776,17 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
.json({ error: 'Base name must be less than 90 characters to allow for numbering' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
permissions !== undefined &&
|
||||||
|
permissions !== null &&
|
||||||
|
permissions !== '' &&
|
||||||
|
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 生成批量API Keys
|
// 生成批量API Keys
|
||||||
const createdKeys = []
|
const createdKeys = []
|
||||||
const errors = []
|
const errors = []
|
||||||
@@ -772,6 +804,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
geminiAccountId,
|
geminiAccountId,
|
||||||
openaiAccountId,
|
openaiAccountId,
|
||||||
bedrockAccountId,
|
bedrockAccountId,
|
||||||
|
droidAccountId,
|
||||||
permissions,
|
permissions,
|
||||||
concurrencyLimit,
|
concurrencyLimit,
|
||||||
rateLimitWindow,
|
rateLimitWindow,
|
||||||
@@ -854,6 +887,15 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
updates.permissions !== undefined &&
|
||||||
|
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
||||||
)
|
)
|
||||||
@@ -939,6 +981,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
if (updates.bedrockAccountId !== undefined) {
|
if (updates.bedrockAccountId !== undefined) {
|
||||||
finalUpdates.bedrockAccountId = updates.bedrockAccountId
|
finalUpdates.bedrockAccountId = updates.bedrockAccountId
|
||||||
}
|
}
|
||||||
|
if (updates.droidAccountId !== undefined) {
|
||||||
|
finalUpdates.droidAccountId = updates.droidAccountId || ''
|
||||||
|
}
|
||||||
|
|
||||||
// 处理标签操作
|
// 处理标签操作
|
||||||
if (updates.tags !== undefined) {
|
if (updates.tags !== undefined) {
|
||||||
@@ -1025,6 +1070,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
geminiAccountId,
|
geminiAccountId,
|
||||||
openaiAccountId,
|
openaiAccountId,
|
||||||
bedrockAccountId,
|
bedrockAccountId,
|
||||||
|
droidAccountId,
|
||||||
permissions,
|
permissions,
|
||||||
enableModelRestriction,
|
enableModelRestriction,
|
||||||
restrictedModels,
|
restrictedModels,
|
||||||
@@ -1116,12 +1162,17 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.bedrockAccountId = bedrockAccountId || ''
|
updates.bedrockAccountId = bedrockAccountId || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (droidAccountId !== undefined) {
|
||||||
|
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||||||
|
updates.droidAccountId = droidAccountId || ''
|
||||||
|
}
|
||||||
|
|
||||||
if (permissions !== undefined) {
|
if (permissions !== undefined) {
|
||||||
// 验证权限值
|
// 验证权限值
|
||||||
if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) {
|
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
||||||
return res
|
return res.status(400).json({
|
||||||
.status(400)
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||||
.json({ error: 'Invalid permissions value. Must be claude, gemini, openai, or all' })
|
})
|
||||||
}
|
}
|
||||||
updates.permissions = permissions
|
updates.permissions = permissions
|
||||||
}
|
}
|
||||||
@@ -4141,7 +4192,14 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
const { platform = 'claude', days = 30 } = req.query
|
const { platform = 'claude', days = 30 } = req.query
|
||||||
|
|
||||||
const allowedPlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
|
const allowedPlatforms = [
|
||||||
|
'claude',
|
||||||
|
'claude-console',
|
||||||
|
'openai',
|
||||||
|
'openai-responses',
|
||||||
|
'gemini',
|
||||||
|
'droid'
|
||||||
|
]
|
||||||
if (!allowedPlatforms.includes(platform)) {
|
if (!allowedPlatforms.includes(platform)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -4151,7 +4209,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
|
|
||||||
const accountTypeMap = {
|
const accountTypeMap = {
|
||||||
openai: 'openai',
|
openai: 'openai',
|
||||||
'openai-responses': 'openai-responses'
|
'openai-responses': 'openai-responses',
|
||||||
|
droid: 'droid'
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackModelMap = {
|
const fallbackModelMap = {
|
||||||
@@ -4159,7 +4218,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
'claude-console': 'claude-3-5-sonnet-20241022',
|
'claude-console': 'claude-3-5-sonnet-20241022',
|
||||||
openai: 'gpt-4o-mini-2024-07-18',
|
openai: 'gpt-4o-mini-2024-07-18',
|
||||||
'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',
|
||||||
|
droid: 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取账户信息以获取创建时间
|
// 获取账户信息以获取创建时间
|
||||||
@@ -4183,6 +4243,9 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
case 'gemini':
|
case 'gemini':
|
||||||
accountData = await geminiAccountService.getAccount(accountId)
|
accountData = await geminiAccountService.getAccount(accountId)
|
||||||
break
|
break
|
||||||
|
case 'droid':
|
||||||
|
accountData = await droidAccountService.getAccount(accountId)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountData && accountData.createdAt) {
|
if (accountData && accountData.createdAt) {
|
||||||
@@ -4387,6 +4450,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
openaiAccounts,
|
openaiAccounts,
|
||||||
ccrAccounts,
|
ccrAccounts,
|
||||||
openaiResponsesAccounts,
|
openaiResponsesAccounts,
|
||||||
|
droidAccounts,
|
||||||
todayStats,
|
todayStats,
|
||||||
systemAverages,
|
systemAverages,
|
||||||
realtimeMetrics
|
realtimeMetrics
|
||||||
@@ -4400,6 +4464,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
redis.getAllOpenAIAccounts(),
|
redis.getAllOpenAIAccounts(),
|
||||||
ccrAccountService.getAllAccounts(),
|
ccrAccountService.getAllAccounts(),
|
||||||
openaiResponsesAccountService.getAllAccounts(true),
|
openaiResponsesAccountService.getAllAccounts(true),
|
||||||
|
droidAccountService.getAllAccounts(),
|
||||||
redis.getTodayStats(),
|
redis.getTodayStats(),
|
||||||
redis.getSystemAverages(),
|
redis.getSystemAverages(),
|
||||||
redis.getRealtimeSystemMetrics()
|
redis.getRealtimeSystemMetrics()
|
||||||
@@ -4407,6 +4472,42 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
// 处理Bedrock账户数据
|
// 处理Bedrock账户数据
|
||||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||||
|
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||||
|
const isRateLimitedFlag = (status) => {
|
||||||
|
if (!status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof status === 'string') {
|
||||||
|
return status === 'limited'
|
||||||
|
}
|
||||||
|
if (typeof status === 'object') {
|
||||||
|
return status.isRateLimited === true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalDroidAccounts = droidAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
normalizeBoolean(acc.isActive) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized' &&
|
||||||
|
normalizeBoolean(acc.schedulable) &&
|
||||||
|
!isRateLimitedFlag(acc.rateLimitStatus)
|
||||||
|
).length
|
||||||
|
const abnormalDroidAccounts = droidAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
!normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||||
|
).length
|
||||||
|
const pausedDroidAccounts = droidAccounts.filter(
|
||||||
|
(acc) =>
|
||||||
|
!normalizeBoolean(acc.schedulable) &&
|
||||||
|
normalizeBoolean(acc.isActive) &&
|
||||||
|
acc.status !== 'blocked' &&
|
||||||
|
acc.status !== 'unauthorized'
|
||||||
|
).length
|
||||||
|
const rateLimitedDroidAccounts = droidAccounts.filter((acc) =>
|
||||||
|
isRateLimitedFlag(acc.rateLimitStatus)
|
||||||
|
).length
|
||||||
|
|
||||||
// 计算使用统计(统一使用allTokens)
|
// 计算使用统计(统一使用allTokens)
|
||||||
const totalTokensUsed = apiKeys.reduce(
|
const totalTokensUsed = apiKeys.reduce(
|
||||||
@@ -4654,7 +4755,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
abnormalBedrockAccounts +
|
abnormalBedrockAccounts +
|
||||||
abnormalOpenAIAccounts +
|
abnormalOpenAIAccounts +
|
||||||
abnormalOpenAIResponsesAccounts +
|
abnormalOpenAIResponsesAccounts +
|
||||||
abnormalCcrAccounts,
|
abnormalCcrAccounts +
|
||||||
|
abnormalDroidAccounts,
|
||||||
pausedAccounts:
|
pausedAccounts:
|
||||||
pausedClaudeAccounts +
|
pausedClaudeAccounts +
|
||||||
pausedClaudeConsoleAccounts +
|
pausedClaudeConsoleAccounts +
|
||||||
@@ -4662,7 +4764,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
pausedBedrockAccounts +
|
pausedBedrockAccounts +
|
||||||
pausedOpenAIAccounts +
|
pausedOpenAIAccounts +
|
||||||
pausedOpenAIResponsesAccounts +
|
pausedOpenAIResponsesAccounts +
|
||||||
pausedCcrAccounts,
|
pausedCcrAccounts +
|
||||||
|
pausedDroidAccounts,
|
||||||
rateLimitedAccounts:
|
rateLimitedAccounts:
|
||||||
rateLimitedClaudeAccounts +
|
rateLimitedClaudeAccounts +
|
||||||
rateLimitedClaudeConsoleAccounts +
|
rateLimitedClaudeConsoleAccounts +
|
||||||
@@ -4670,7 +4773,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
rateLimitedBedrockAccounts +
|
rateLimitedBedrockAccounts +
|
||||||
rateLimitedOpenAIAccounts +
|
rateLimitedOpenAIAccounts +
|
||||||
rateLimitedOpenAIResponsesAccounts +
|
rateLimitedOpenAIResponsesAccounts +
|
||||||
rateLimitedCcrAccounts,
|
rateLimitedCcrAccounts +
|
||||||
|
rateLimitedDroidAccounts,
|
||||||
// 各平台详细统计
|
// 各平台详细统计
|
||||||
accountsByPlatform: {
|
accountsByPlatform: {
|
||||||
claude: {
|
claude: {
|
||||||
@@ -4721,6 +4825,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
abnormal: abnormalOpenAIResponsesAccounts,
|
abnormal: abnormalOpenAIResponsesAccounts,
|
||||||
paused: pausedOpenAIResponsesAccounts,
|
paused: pausedOpenAIResponsesAccounts,
|
||||||
rateLimited: rateLimitedOpenAIResponsesAccounts
|
rateLimited: rateLimitedOpenAIResponsesAccounts
|
||||||
|
},
|
||||||
|
droid: {
|
||||||
|
total: droidAccounts.length,
|
||||||
|
normal: normalDroidAccounts,
|
||||||
|
abnormal: abnormalDroidAccounts,
|
||||||
|
paused: pausedDroidAccounts,
|
||||||
|
rateLimited: rateLimitedDroidAccounts
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 保留旧字段以兼容
|
// 保留旧字段以兼容
|
||||||
@@ -4731,7 +4842,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
normalBedrockAccounts +
|
normalBedrockAccounts +
|
||||||
normalOpenAIAccounts +
|
normalOpenAIAccounts +
|
||||||
normalOpenAIResponsesAccounts +
|
normalOpenAIResponsesAccounts +
|
||||||
normalCcrAccounts,
|
normalCcrAccounts +
|
||||||
|
normalDroidAccounts,
|
||||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||||
@@ -4769,6 +4881,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
redisConnected: redis.isConnected,
|
redisConnected: redis.isConnected,
|
||||||
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0,
|
||||||
geminiAccountsHealthy: normalGeminiAccounts > 0,
|
geminiAccountsHealthy: normalGeminiAccounts > 0,
|
||||||
|
droidAccountsHealthy: normalDroidAccounts > 0,
|
||||||
uptime: process.uptime()
|
uptime: process.uptime()
|
||||||
},
|
},
|
||||||
systemTimezone: config.system.timezoneOffset || 8
|
systemTimezone: config.system.timezoneOffset || 8
|
||||||
@@ -8357,4 +8470,246 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 🤖 Droid 账户管理
|
||||||
|
|
||||||
|
// 生成 Droid OAuth 授权链接
|
||||||
|
router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { proxy } = req.body || {}
|
||||||
|
const deviceAuth = await startDeviceAuthorization(proxy || null)
|
||||||
|
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString()
|
||||||
|
|
||||||
|
await redis.setOAuthSession(sessionId, {
|
||||||
|
deviceCode: deviceAuth.deviceCode,
|
||||||
|
userCode: deviceAuth.userCode,
|
||||||
|
verificationUri: deviceAuth.verificationUri,
|
||||||
|
verificationUriComplete: deviceAuth.verificationUriComplete,
|
||||||
|
interval: deviceAuth.interval,
|
||||||
|
proxy: proxy || null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId })
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
userCode: deviceAuth.userCode,
|
||||||
|
verificationUri: deviceAuth.verificationUri,
|
||||||
|
verificationUriComplete: deviceAuth.verificationUriComplete,
|
||||||
|
expiresIn: deviceAuth.expiresIn,
|
||||||
|
interval: deviceAuth.interval,
|
||||||
|
instructions: [
|
||||||
|
'1. 使用下方验证码进入授权页面并确认访问权限。',
|
||||||
|
'2. 在授权页面登录 Factory / Droid 账户并点击允许。',
|
||||||
|
'3. 回到此处点击“完成授权”完成凭证获取。'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误'
|
||||||
|
logger.error('❌ 生成 Droid 设备码授权失败:', message)
|
||||||
|
return res.status(500).json({ error: 'Failed to start Droid device authorization', message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 交换 Droid 授权码
|
||||||
|
router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => {
|
||||||
|
const { sessionId, proxy } = req.body || {}
|
||||||
|
try {
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: 'Session ID is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||||
|
if (!oauthSession) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired OAuth session' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'OAuth session has expired, please generate a new authorization URL' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oauthSession.deviceCode) {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res.status(400).json({ error: 'OAuth session missing device code, please retry' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyConfig = proxy || oauthSession.proxy || null
|
||||||
|
const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig)
|
||||||
|
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
|
||||||
|
logger.success('🤖 成功获取 Droid 访问令牌', { sessionId })
|
||||||
|
return res.json({ success: true, data: { tokens } })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WorkOSDeviceAuthError) {
|
||||||
|
if (error.code === 'authorization_pending' || error.code === 'slow_down') {
|
||||||
|
const oauthSession = await redis.getOAuthSession(sessionId)
|
||||||
|
const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null
|
||||||
|
const remainingSeconds =
|
||||||
|
expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())
|
||||||
|
? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
pending: true,
|
||||||
|
error: error.code,
|
||||||
|
message: error.message,
|
||||||
|
retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5,
|
||||||
|
expiresIn: remainingSeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'expired_token') {
|
||||||
|
await redis.deleteOAuthSession(sessionId)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Device code expired',
|
||||||
|
message: '授权已过期,请重新生成设备码并再次授权'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ Droid 授权失败:', error.message)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to exchange Droid authorization code',
|
||||||
|
message: error.message,
|
||||||
|
errorCode: error.code
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ 交换 Droid 授权码失败:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to exchange Droid authorization code',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有 Droid 账户
|
||||||
|
router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await droidAccountService.getAllAccounts()
|
||||||
|
const allApiKeys = await redis.getAllApiKeys()
|
||||||
|
|
||||||
|
// 添加使用统计
|
||||||
|
const accountsWithStats = await Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||||
|
let groupInfos = []
|
||||||
|
try {
|
||||||
|
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
} catch (groupError) {
|
||||||
|
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError)
|
||||||
|
groupInfos = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = groupInfos.map((group) => group.id)
|
||||||
|
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||||
|
const binding = key.droidAccountId
|
||||||
|
if (!binding) {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
if (binding === account.id) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
if (binding.startsWith('group:')) {
|
||||||
|
const groupId = binding.substring('group:'.length)
|
||||||
|
if (groupIds.includes(groupId)) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
schedulable: account.schedulable === 'true',
|
||||||
|
boundApiKeysCount,
|
||||||
|
groupInfos,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message)
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
boundApiKeysCount: 0,
|
||||||
|
groupInfos: [],
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0 },
|
||||||
|
total: { tokens: 0, requests: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: accountsWithStats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get Droid accounts:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建 Droid 账户
|
||||||
|
router.post('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const account = await droidAccountService.createAccount(req.body)
|
||||||
|
logger.success(`Created Droid account: ${account.name} (${account.id})`)
|
||||||
|
return res.json({ success: true, data: account })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create Droid account:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to create Droid account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新 Droid 账户
|
||||||
|
router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const account = await droidAccountService.updateAccount(id, req.body)
|
||||||
|
return res.json({ success: true, data: account })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update Droid account ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({ error: 'Failed to update Droid account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除 Droid 账户
|
||||||
|
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
await droidAccountService.deleteAccount(id)
|
||||||
|
return res.json({ success: true, message: 'Droid account deleted successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to delete Droid account ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 刷新 Droid 账户 token
|
||||||
|
router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const result = await droidAccountService.refreshAccessToken(id)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({ error: 'Failed to refresh token', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
191
src/routes/droidRoutes.js
Normal file
191
src/routes/droidRoutes.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const express = require('express')
|
||||||
|
const { authenticateApiKey } = require('../middleware/auth')
|
||||||
|
const droidRelayService = require('../services/droidRelayService')
|
||||||
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
function hasDroidPermission(apiKeyData) {
|
||||||
|
const permissions = apiKeyData?.permissions || 'all'
|
||||||
|
return permissions === 'all' || permissions === 'droid'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Droid API 转发路由
|
||||||
|
*
|
||||||
|
* 支持的 Factory.ai 端点:
|
||||||
|
* - /droid/claude - Anthropic (Claude) Messages API
|
||||||
|
* - /droid/openai - OpenAI Responses API
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Claude (Anthropic) 端点 - /v1/messages
|
||||||
|
router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
if (!hasDroidPermission(req.apiKey)) {
|
||||||
|
logger.security(
|
||||||
|
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'permission_denied',
|
||||||
|
message: '此 API Key 未启用 Droid 权限'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await droidRelayService.relayRequest(
|
||||||
|
req.body,
|
||||||
|
req.apiKey,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
req.headers,
|
||||||
|
{ endpointType: 'anthropic', sessionHash }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果是流式响应,已经在 relayService 中处理了
|
||||||
|
if (result.streaming) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非流式响应
|
||||||
|
res.status(result.statusCode).set(result.headers).send(result.body)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Droid Claude relay error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'internal_server_error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/claude/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestBody = { ...req.body }
|
||||||
|
if ('stream' in requestBody) {
|
||||||
|
delete requestBody.stream
|
||||||
|
}
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
||||||
|
|
||||||
|
if (!hasDroidPermission(req.apiKey)) {
|
||||||
|
logger.security(
|
||||||
|
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'permission_denied',
|
||||||
|
message: '此 API Key 未启用 Droid 权限'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await droidRelayService.relayRequest(
|
||||||
|
requestBody,
|
||||||
|
req.apiKey,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
req.headers,
|
||||||
|
{
|
||||||
|
endpointType: 'anthropic',
|
||||||
|
sessionHash,
|
||||||
|
customPath: '/a/v1/messages/count_tokens',
|
||||||
|
skipUsageRecord: true,
|
||||||
|
disableStreaming: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
res.status(result.statusCode).set(result.headers).send(result.body)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Droid Claude count_tokens relay error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'internal_server_error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// OpenAI 端点 - /v1/responses
|
||||||
|
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessionId =
|
||||||
|
req.headers['session_id'] ||
|
||||||
|
req.headers['x-session-id'] ||
|
||||||
|
req.body?.session_id ||
|
||||||
|
req.body?.conversation_id ||
|
||||||
|
null
|
||||||
|
|
||||||
|
const sessionHash = sessionId
|
||||||
|
? crypto.createHash('sha256').update(String(sessionId)).digest('hex')
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!hasDroidPermission(req.apiKey)) {
|
||||||
|
logger.security(
|
||||||
|
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'permission_denied',
|
||||||
|
message: '此 API Key 未启用 Droid 权限'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await droidRelayService.relayRequest(
|
||||||
|
req.body,
|
||||||
|
req.apiKey,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
req.headers,
|
||||||
|
{ endpointType: 'openai', sessionHash }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.streaming) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(result.statusCode).set(result.headers).send(result.body)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Droid OpenAI relay error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'internal_server_error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模型列表端点(兼容性)
|
||||||
|
router.get('/*/v1/models', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 返回可用的模型列表
|
||||||
|
const models = [
|
||||||
|
{
|
||||||
|
id: 'claude-opus-4-1-20250805',
|
||||||
|
object: 'model',
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: 'anthropic'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-4-5-20250929',
|
||||||
|
object: 'model',
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: 'anthropic'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-5-2025-08-07',
|
||||||
|
object: 'model',
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: 'openai'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
object: 'list',
|
||||||
|
data: models
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Droid models list error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'internal_server_error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -27,8 +27,8 @@ class AccountGroupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证平台类型
|
// 验证平台类型
|
||||||
if (!['claude', 'gemini', 'openai'].includes(platform)) {
|
if (!['claude', 'gemini', 'openai', 'droid'].includes(platform)) {
|
||||||
throw new Error('平台类型必须是 claude、gemini 或 openai')
|
throw new Error('平台类型必须是 claude、gemini、openai 或 droid')
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -311,7 +311,8 @@ class AccountGroupService {
|
|||||||
keyData &&
|
keyData &&
|
||||||
(keyData.claudeAccountId === groupKey ||
|
(keyData.claudeAccountId === groupKey ||
|
||||||
keyData.geminiAccountId === groupKey ||
|
keyData.geminiAccountId === groupKey ||
|
||||||
keyData.openaiAccountId === groupKey)
|
keyData.openaiAccountId === groupKey ||
|
||||||
|
keyData.droidAccountId === groupKey)
|
||||||
) {
|
) {
|
||||||
boundApiKeys.push({
|
boundApiKeys.push({
|
||||||
id: keyId,
|
id: keyId,
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ class ApiKeyService {
|
|||||||
openaiAccountId = null,
|
openaiAccountId = null,
|
||||||
azureOpenaiAccountId = null,
|
azureOpenaiAccountId = null,
|
||||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||||
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
|
droidAccountId = null,
|
||||||
|
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
||||||
isActive = true,
|
isActive = true,
|
||||||
concurrencyLimit = 0,
|
concurrencyLimit = 0,
|
||||||
rateLimitWindow = null,
|
rateLimitWindow = null,
|
||||||
@@ -64,6 +65,7 @@ class ApiKeyService {
|
|||||||
openaiAccountId: openaiAccountId || '',
|
openaiAccountId: openaiAccountId || '',
|
||||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||||
|
droidAccountId: droidAccountId || '',
|
||||||
permissions: permissions || 'all',
|
permissions: permissions || 'all',
|
||||||
enableModelRestriction: String(enableModelRestriction),
|
enableModelRestriction: String(enableModelRestriction),
|
||||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||||
@@ -109,6 +111,7 @@ class ApiKeyService {
|
|||||||
openaiAccountId: keyData.openaiAccountId,
|
openaiAccountId: keyData.openaiAccountId,
|
||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions,
|
permissions: keyData.permissions,
|
||||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||||
@@ -256,6 +259,7 @@ class ApiKeyService {
|
|||||||
openaiAccountId: keyData.openaiAccountId,
|
openaiAccountId: keyData.openaiAccountId,
|
||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions || 'all',
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
@@ -382,6 +386,7 @@ class ApiKeyService {
|
|||||||
openaiAccountId: keyData.openaiAccountId,
|
openaiAccountId: keyData.openaiAccountId,
|
||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId,
|
bedrockAccountId: keyData.bedrockAccountId,
|
||||||
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions || 'all',
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
@@ -553,6 +558,7 @@ class ApiKeyService {
|
|||||||
'openaiAccountId',
|
'openaiAccountId',
|
||||||
'azureOpenaiAccountId',
|
'azureOpenaiAccountId',
|
||||||
'bedrockAccountId', // 添加 Bedrock 账号ID
|
'bedrockAccountId', // 添加 Bedrock 账号ID
|
||||||
|
'droidAccountId',
|
||||||
'permissions',
|
'permissions',
|
||||||
'expiresAt',
|
'expiresAt',
|
||||||
'activationDays', // 新增:激活后有效天数
|
'activationDays', // 新增:激活后有效天数
|
||||||
@@ -1211,6 +1217,7 @@ class ApiKeyService {
|
|||||||
userId: key.userId,
|
userId: key.userId,
|
||||||
userUsername: key.userUsername,
|
userUsername: key.userUsername,
|
||||||
createdBy: key.createdBy,
|
createdBy: key.createdBy,
|
||||||
|
droidAccountId: key.droidAccountId,
|
||||||
// Include deletion fields for deleted keys
|
// Include deletion fields for deleted keys
|
||||||
isDeleted: key.isDeleted,
|
isDeleted: key.isDeleted,
|
||||||
deletedAt: key.deletedAt,
|
deletedAt: key.deletedAt,
|
||||||
@@ -1254,7 +1261,8 @@ class ApiKeyService {
|
|||||||
createdBy: keyData.createdBy,
|
createdBy: keyData.createdBy,
|
||||||
permissions: keyData.permissions,
|
permissions: keyData.permissions,
|
||||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0)
|
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||||
|
droidAccountId: keyData.droidAccountId
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get API key by ID:', error)
|
logger.error('❌ Failed to get API key by ID:', error)
|
||||||
@@ -1401,6 +1409,7 @@ class ApiKeyService {
|
|||||||
'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀
|
'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀
|
||||||
azure_openai: 'azureOpenaiAccountId',
|
azure_openai: 'azureOpenaiAccountId',
|
||||||
bedrock: 'bedrockAccountId',
|
bedrock: 'bedrockAccountId',
|
||||||
|
droid: 'droidAccountId',
|
||||||
ccr: null // CCR 账号没有对应的 API Key 字段
|
ccr: null // CCR 账号没有对应的 API Key 字段
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1247
src/services/droidAccountService.js
Normal file
1247
src/services/droidAccountService.js
Normal file
File diff suppressed because it is too large
Load Diff
1153
src/services/droidRelayService.js
Normal file
1153
src/services/droidRelayService.js
Normal file
File diff suppressed because it is too large
Load Diff
218
src/services/droidScheduler.js
Normal file
218
src/services/droidScheduler.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
const droidAccountService = require('./droidAccountService')
|
||||||
|
const accountGroupService = require('./accountGroupService')
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
class DroidScheduler {
|
||||||
|
constructor() {
|
||||||
|
this.STICKY_PREFIX = 'droid'
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeEndpointType(endpointType) {
|
||||||
|
if (!endpointType) {
|
||||||
|
return 'anthropic'
|
||||||
|
}
|
||||||
|
const normalized = String(endpointType).toLowerCase()
|
||||||
|
if (normalized === 'openai' || normalized === 'common') {
|
||||||
|
return 'openai'
|
||||||
|
}
|
||||||
|
return 'anthropic'
|
||||||
|
}
|
||||||
|
|
||||||
|
_isTruthy(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
return Boolean(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
_isAccountActive(account) {
|
||||||
|
if (!account) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const isActive = this._isTruthy(account.isActive)
|
||||||
|
if (!isActive) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = (account.status || 'active').toLowerCase()
|
||||||
|
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
|
||||||
|
return !unhealthyStatuses.has(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
_isAccountSchedulable(account) {
|
||||||
|
return this._isTruthy(account?.schedulable ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
_matchesEndpoint(account, endpointType) {
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
|
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
|
||||||
|
if (normalizedEndpoint === accountEndpoint) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||||
|
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
_sortCandidates(candidates) {
|
||||||
|
return [...candidates].sort((a, b) => {
|
||||||
|
const priorityA = parseInt(a.priority, 10) || 50
|
||||||
|
const priorityB = parseInt(b.priority, 10) || 50
|
||||||
|
|
||||||
|
if (priorityA !== priorityB) {
|
||||||
|
return priorityA - priorityB
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||||
|
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||||
|
|
||||||
|
if (lastUsedA !== lastUsedB) {
|
||||||
|
return lastUsedA - lastUsedB
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||||
|
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||||
|
return createdA - createdB
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
||||||
|
if (!sessionHash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
|
const apiKeyPart = apiKeyId || 'default'
|
||||||
|
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadGroupAccounts(groupId) {
|
||||||
|
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||||
|
if (!memberIds || memberIds.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await Promise.all(
|
||||||
|
memberIds.map(async (memberId) => {
|
||||||
|
try {
|
||||||
|
return await droidAccountService.getAccount(memberId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return accounts.filter(
|
||||||
|
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async _ensureLastUsedUpdated(accountId) {
|
||||||
|
try {
|
||||||
|
await droidAccountService.touchLastUsedAt(accountId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cleanupStickyMapping(stickyKey) {
|
||||||
|
if (!stickyKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await redis.deleteSessionAccountMapping(stickyKey)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
|
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
||||||
|
|
||||||
|
let candidates = []
|
||||||
|
let isDedicatedBinding = false
|
||||||
|
|
||||||
|
if (apiKeyData?.droidAccountId) {
|
||||||
|
const binding = apiKeyData.droidAccountId
|
||||||
|
if (binding.startsWith('group:')) {
|
||||||
|
const groupId = binding.substring('group:'.length)
|
||||||
|
logger.info(
|
||||||
|
`🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度`
|
||||||
|
)
|
||||||
|
candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint)
|
||||||
|
} else {
|
||||||
|
const account = await droidAccountService.getAccount(binding)
|
||||||
|
if (account) {
|
||||||
|
candidates = [account]
|
||||||
|
isDedicatedBinding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidates || candidates.length === 0) {
|
||||||
|
candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = candidates.filter(
|
||||||
|
(account) =>
|
||||||
|
account &&
|
||||||
|
this._isAccountActive(account) &&
|
||||||
|
this._isAccountSchedulable(account) &&
|
||||||
|
this._matchesEndpoint(account, normalizedEndpoint)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`No available Droid accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stickyKey && !isDedicatedBinding) {
|
||||||
|
const mappedAccountId = await redis.getSessionAccountMapping(stickyKey)
|
||||||
|
if (mappedAccountId) {
|
||||||
|
const mappedAccount = filtered.find((account) => account.id === mappedAccountId)
|
||||||
|
if (mappedAccount) {
|
||||||
|
await redis.extendSessionAccountMappingTTL(stickyKey)
|
||||||
|
logger.info(
|
||||||
|
`🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}`
|
||||||
|
)
|
||||||
|
await this._ensureLastUsedUpdated(mappedAccount.id)
|
||||||
|
return mappedAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._cleanupStickyMapping(stickyKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = this._sortCandidates(filtered)
|
||||||
|
const selected = sorted[0]
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
throw new Error(
|
||||||
|
`No schedulable Droid account available after sorting (${normalizedEndpoint})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stickyKey && !isDedicatedBinding) {
|
||||||
|
await redis.setSessionAccountMapping(stickyKey, selected.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._ensureLastUsedUpdated(selected.id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🤖 选择 Droid 账号 ${selected.name || selected.id}(endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new DroidScheduler()
|
||||||
@@ -17,8 +17,18 @@ function maskToken(token, visiblePercent = 70) {
|
|||||||
const { length } = token
|
const { length } = token
|
||||||
|
|
||||||
// 对于非常短的 token,至少隐藏一部分
|
// 对于非常短的 token,至少隐藏一部分
|
||||||
|
if (length <= 2) {
|
||||||
|
return '*'.repeat(length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length <= 5) {
|
||||||
|
return token.slice(0, 1) + '*'.repeat(length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
if (length <= 10) {
|
if (length <= 10) {
|
||||||
return token.slice(0, 5) + '*'.repeat(length - 5)
|
const visibleLength = Math.min(5, length - 2)
|
||||||
|
const front = token.slice(0, visibleLength)
|
||||||
|
return front + '*'.repeat(length - visibleLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算可见字符数量
|
// 计算可见字符数量
|
||||||
|
|||||||
170
src/utils/workosOAuthHelper.js
Normal file
170
src/utils/workosOAuthHelper.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const ProxyHelper = require('./proxyHelper')
|
||||||
|
|
||||||
|
const WORKOS_CONFIG = config.droid || {}
|
||||||
|
|
||||||
|
const WORKOS_DEVICE_AUTHORIZE_URL =
|
||||||
|
WORKOS_CONFIG.deviceAuthorizeUrl || 'https://api.workos.com/user_management/authorize/device'
|
||||||
|
const WORKOS_TOKEN_URL =
|
||||||
|
WORKOS_CONFIG.tokenUrl || 'https://api.workos.com/user_management/authenticate'
|
||||||
|
const WORKOS_CLIENT_ID = WORKOS_CONFIG.clientId || 'client_01HNM792M5G5G1A2THWPXKFMXB'
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL = 5
|
||||||
|
|
||||||
|
class WorkOSDeviceAuthError extends Error {
|
||||||
|
constructor(message, code, options = {}) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'WorkOSDeviceAuthError'
|
||||||
|
this.code = code || 'unknown_error'
|
||||||
|
this.retryAfter = options.retryAfter || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动设备码授权流程
|
||||||
|
* @param {object|null} proxyConfig - 代理配置
|
||||||
|
* @returns {Promise<object>} WorkOS 返回的数据
|
||||||
|
*/
|
||||||
|
async function startDeviceAuthorization(proxyConfig = null) {
|
||||||
|
const form = new URLSearchParams({
|
||||||
|
client_id: WORKOS_CLIENT_ID
|
||||||
|
})
|
||||||
|
|
||||||
|
const agent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('🔐 请求 WorkOS 设备码授权', {
|
||||||
|
url: WORKOS_DEVICE_AUTHORIZE_URL,
|
||||||
|
hasProxy: !!agent
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await axios.post(WORKOS_DEVICE_AUTHORIZE_URL, form.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
httpsAgent: agent,
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = response.data || {}
|
||||||
|
|
||||||
|
if (!data.device_code || !data.verification_uri) {
|
||||||
|
throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('✅ 成功获取 WorkOS 设备码授权信息', {
|
||||||
|
verificationUri: data.verification_uri,
|
||||||
|
userCode: data.user_code
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceCode: data.device_code,
|
||||||
|
userCode: data.user_code,
|
||||||
|
verificationUri: data.verification_uri,
|
||||||
|
verificationUriComplete: data.verification_uri_complete || data.verification_uri,
|
||||||
|
expiresIn: data.expires_in || 300,
|
||||||
|
interval: data.interval || DEFAULT_POLL_INTERVAL
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response) {
|
||||||
|
logger.error('❌ WorkOS 设备码授权失败', {
|
||||||
|
status: error.response.status,
|
||||||
|
data: error.response.data
|
||||||
|
})
|
||||||
|
throw new WorkOSDeviceAuthError(
|
||||||
|
error.response.data?.error_description ||
|
||||||
|
error.response.data?.error ||
|
||||||
|
'WorkOS 设备码授权失败',
|
||||||
|
error.response.data?.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ 请求 WorkOS 设备码授权异常', {
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
throw new WorkOSDeviceAuthError(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询授权结果
|
||||||
|
* @param {string} deviceCode - 设备码
|
||||||
|
* @param {object|null} proxyConfig - 代理配置
|
||||||
|
* @returns {Promise<object>} WorkOS 返回的 token 数据
|
||||||
|
*/
|
||||||
|
async function pollDeviceAuthorization(deviceCode, proxyConfig = null) {
|
||||||
|
if (!deviceCode) {
|
||||||
|
throw new WorkOSDeviceAuthError('缺少设备码,无法查询授权结果', 'missing_device_code')
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new URLSearchParams({
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||||
|
device_code: deviceCode,
|
||||||
|
client_id: WORKOS_CLIENT_ID
|
||||||
|
})
|
||||||
|
|
||||||
|
const agent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(WORKOS_TOKEN_URL, form.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
httpsAgent: agent,
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = response.data || {}
|
||||||
|
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new WorkOSDeviceAuthError('WorkOS 返回结果缺少 access_token', 'missing_access_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('🤖 Droid 授权完成,获取到访问令牌', {
|
||||||
|
hasRefreshToken: !!data.refresh_token
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response) {
|
||||||
|
const responseData = error.response.data || {}
|
||||||
|
const errorCode = responseData.error || `http_${error.response.status}`
|
||||||
|
const errorDescription =
|
||||||
|
responseData.error_description || responseData.error || 'WorkOS 授权失败'
|
||||||
|
|
||||||
|
if (errorCode === 'authorization_pending' || errorCode === 'slow_down') {
|
||||||
|
const retryAfter =
|
||||||
|
Number(responseData.interval) ||
|
||||||
|
Number(error.response.headers?.['retry-after']) ||
|
||||||
|
DEFAULT_POLL_INTERVAL
|
||||||
|
|
||||||
|
throw new WorkOSDeviceAuthError(errorDescription, errorCode, {
|
||||||
|
retryAfter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCode === 'expired_token') {
|
||||||
|
throw new WorkOSDeviceAuthError(errorDescription, errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ WorkOS 设备授权轮询失败', {
|
||||||
|
status: error.response.status,
|
||||||
|
data: responseData
|
||||||
|
})
|
||||||
|
throw new WorkOSDeviceAuthError(errorDescription, errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ WorkOS 设备授权轮询异常', {
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
throw new WorkOSDeviceAuthError(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
startDeviceAuthorization,
|
||||||
|
pollDeviceAuthorization,
|
||||||
|
WorkOSDeviceAuthError
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ const CLIENT_DEFINITIONS = {
|
|||||||
displayName: 'Codex Command Line Tool',
|
displayName: 'Codex Command Line Tool',
|
||||||
description: 'Cursor/Codex command-line interface',
|
description: 'Cursor/Codex command-line interface',
|
||||||
icon: '🔷'
|
icon: '🔷'
|
||||||
|
},
|
||||||
|
|
||||||
|
DROID_CLI: {
|
||||||
|
id: 'droid_cli',
|
||||||
|
name: 'Droid CLI',
|
||||||
|
displayName: 'Factory Droid CLI',
|
||||||
|
description: 'Factory Droid platform command-line interface',
|
||||||
|
icon: '🤖'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +41,8 @@ const CLIENT_DEFINITIONS = {
|
|||||||
const CLIENT_IDS = {
|
const CLIENT_IDS = {
|
||||||
CLAUDE_CODE: 'claude_code',
|
CLAUDE_CODE: 'claude_code',
|
||||||
GEMINI_CLI: 'gemini_cli',
|
GEMINI_CLI: 'gemini_cli',
|
||||||
CODEX_CLI: 'codex_cli'
|
CODEX_CLI: 'codex_cli',
|
||||||
|
DROID_CLI: 'droid_cli'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有客户端定义
|
// 获取所有客户端定义
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinit
|
|||||||
const ClaudeCodeValidator = require('./clients/claudeCodeValidator')
|
const ClaudeCodeValidator = require('./clients/claudeCodeValidator')
|
||||||
const GeminiCliValidator = require('./clients/geminiCliValidator')
|
const GeminiCliValidator = require('./clients/geminiCliValidator')
|
||||||
const CodexCliValidator = require('./clients/codexCliValidator')
|
const CodexCliValidator = require('./clients/codexCliValidator')
|
||||||
|
const DroidCliValidator = require('./clients/droidCliValidator')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端验证器类
|
* 客户端验证器类
|
||||||
@@ -26,6 +27,8 @@ class ClientValidator {
|
|||||||
return GeminiCliValidator
|
return GeminiCliValidator
|
||||||
case 'codex_cli':
|
case 'codex_cli':
|
||||||
return CodexCliValidator
|
return CodexCliValidator
|
||||||
|
case 'droid_cli':
|
||||||
|
return DroidCliValidator
|
||||||
default:
|
default:
|
||||||
logger.warn(`Unknown client ID: ${clientId}`)
|
logger.warn(`Unknown client ID: ${clientId}`)
|
||||||
return null
|
return null
|
||||||
@@ -37,7 +40,7 @@ class ClientValidator {
|
|||||||
* @returns {Array<string>} 客户端ID列表
|
* @returns {Array<string>} 客户端ID列表
|
||||||
*/
|
*/
|
||||||
static getSupportedClients() {
|
static getSupportedClients() {
|
||||||
return ['claude_code', 'gemini_cli', 'codex_cli']
|
return ['claude_code', 'gemini_cli', 'codex_cli', 'droid_cli']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ class ClaudeCodeValidator {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemEntries = Array.isArray(body.system) ? body.system : []
|
const systemEntries = Array.isArray(body.system) ? body.system : null
|
||||||
|
if (!systemEntries) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of systemEntries) {
|
for (const entry of systemEntries) {
|
||||||
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||||
|
|||||||
57
src/validators/clients/droidCliValidator.js
Normal file
57
src/validators/clients/droidCliValidator.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const { CLIENT_DEFINITIONS } = require('../clientDefinitions')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Droid CLI 验证器
|
||||||
|
* 检查请求是否来自 Factory Droid CLI
|
||||||
|
*/
|
||||||
|
class DroidCliValidator {
|
||||||
|
static getId() {
|
||||||
|
return CLIENT_DEFINITIONS.DROID_CLI.id
|
||||||
|
}
|
||||||
|
|
||||||
|
static getName() {
|
||||||
|
return CLIENT_DEFINITIONS.DROID_CLI.name
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDescription() {
|
||||||
|
return CLIENT_DEFINITIONS.DROID_CLI.description
|
||||||
|
}
|
||||||
|
|
||||||
|
static validate(req) {
|
||||||
|
try {
|
||||||
|
const userAgent = req.headers['user-agent'] || ''
|
||||||
|
const factoryClientHeader = (req.headers['x-factory-client'] || '').toString().toLowerCase()
|
||||||
|
|
||||||
|
const uaMatch = /factory-cli\/(\d+\.\d+\.\d+)/i.exec(userAgent)
|
||||||
|
const hasFactoryClientHeader =
|
||||||
|
typeof factoryClientHeader === 'string' &&
|
||||||
|
(factoryClientHeader.includes('droid') || factoryClientHeader.includes('factory-cli'))
|
||||||
|
|
||||||
|
if (!uaMatch && !hasFactoryClientHeader) {
|
||||||
|
logger.debug(`Droid CLI validation failed - UA mismatch: ${userAgent}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 允许,通过基础验证
|
||||||
|
logger.debug(
|
||||||
|
`Droid CLI validation passed (UA: ${userAgent || 'N/A'}, header: ${factoryClientHeader || 'N/A'})`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in DroidCliValidator:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInfo() {
|
||||||
|
return {
|
||||||
|
id: this.getId(),
|
||||||
|
name: this.getName(),
|
||||||
|
description: this.getDescription(),
|
||||||
|
icon: CLIENT_DEFINITIONS.DROID_CLI.icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DroidCliValidator
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -352,7 +352,8 @@ const platformLabelMap = {
|
|||||||
'claude-console': 'Claude Console',
|
'claude-console': 'Claude Console',
|
||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
'openai-responses': 'OpenAI Responses',
|
'openai-responses': 'OpenAI Responses',
|
||||||
gemini: 'Gemini'
|
gemini: 'Gemini',
|
||||||
|
droid: 'Droid'
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')
|
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')
|
||||||
|
|||||||
@@ -58,6 +58,10 @@
|
|||||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="openai" />
|
<input v-model="createForm.platform" class="mr-2" type="radio" value="openai" />
|
||||||
<span class="text-sm text-gray-700">OpenAI</span>
|
<span class="text-sm text-gray-700">OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input v-model="createForm.platform" class="mr-2" type="radio" value="droid" />
|
||||||
|
<span class="text-sm text-gray-700">Droid</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,7 +124,9 @@
|
|||||||
? 'bg-purple-100 text-purple-700'
|
? 'bg-purple-100 text-purple-700'
|
||||||
: group.platform === 'gemini'
|
: group.platform === 'gemini'
|
||||||
? 'bg-blue-100 text-blue-700'
|
? 'bg-blue-100 text-blue-700'
|
||||||
: 'bg-gray-100 text-gray-700'
|
: group.platform === 'openai'
|
||||||
|
? 'bg-gray-100 text-gray-700'
|
||||||
|
: 'bg-cyan-100 text-cyan-700'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
@@ -128,7 +134,9 @@
|
|||||||
? 'Claude'
|
? 'Claude'
|
||||||
: group.platform === 'gemini'
|
: group.platform === 'gemini'
|
||||||
? 'Gemini'
|
? 'Gemini'
|
||||||
: 'OpenAI'
|
: group.platform === 'openai'
|
||||||
|
? 'OpenAI'
|
||||||
|
: 'Droid'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -464,6 +464,170 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid OAuth流程 -->
|
||||||
|
<div v-else-if="platform === 'droid'">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-cyan-200 bg-cyan-50 p-6 dark:border-cyan-700 dark:bg-cyan-900/30"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-cyan-500"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot text-white" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="mb-3 font-semibold text-cyan-900 dark:text-cyan-200">Droid 账户授权</h4>
|
||||||
|
<p class="mb-4 text-sm text-cyan-800 dark:text-cyan-300">
|
||||||
|
请按照以下步骤完成 Factory (Droid) 账户的授权:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 步骤1: 生成授权链接 -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-cyan-300 bg-white/80 p-4 dark:border-cyan-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-cyan-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-cyan-900 dark:text-cyan-200">
|
||||||
|
点击下方按钮生成授权链接
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="!authUrl"
|
||||||
|
class="btn btn-primary px-4 py-2 text-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="generateAuthUrl"
|
||||||
|
>
|
||||||
|
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||||
|
<div v-else class="loading-spinner mr-2" />
|
||||||
|
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||||
|
</button>
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-300"
|
||||||
|
>授权链接</label
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 rounded-md border border-cyan-200 bg-white p-3 dark:border-cyan-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||||
|
readonly
|
||||||
|
type="text"
|
||||||
|
:value="authUrl"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||||
|
title="复制链接"
|
||||||
|
@click="copyAuthUrl"
|
||||||
|
>
|
||||||
|
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-cyan-200 bg-white px-3 py-1.5 text-xs font-medium text-cyan-600 shadow-sm transition-colors hover:border-cyan-300 hover:bg-cyan-50 dark:border-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-200 dark:hover:border-cyan-500 dark:hover:bg-cyan-900/60"
|
||||||
|
@click="openVerificationPage"
|
||||||
|
>
|
||||||
|
<i class="fas fa-external-link-alt text-xs" /> 在新标签中打开
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-xs font-medium text-cyan-600 transition-colors hover:text-cyan-700 dark:text-cyan-300 dark:hover:text-cyan-200"
|
||||||
|
@click="regenerateAuthUrl"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt text-xs" />重新生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-300"
|
||||||
|
>授权验证码</label
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md border border-cyan-200 bg-cyan-50 px-4 py-3 dark:border-cyan-700 dark:bg-cyan-900/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-mono text-xl font-semibold text-cyan-700 dark:text-cyan-200"
|
||||||
|
>
|
||||||
|
{{ userCode || '------' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-white px-3 py-1 text-sm text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-800 dark:text-cyan-200 dark:hover:bg-cyan-700"
|
||||||
|
@click="copyUserCode"
|
||||||
|
>
|
||||||
|
<i class="fas fa-copy mr-1" />复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<i class="fas fa-hourglass-half mr-1 text-cyan-500" />
|
||||||
|
剩余有效期:{{ formattedCountdown }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤2: 访问链接并授权 -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-cyan-300 bg-white/80 p-4 dark:border-cyan-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-cyan-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-cyan-900 dark:text-cyan-200">
|
||||||
|
在浏览器中打开链接并完成授权
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-sm text-cyan-700 dark:text-cyan-300">
|
||||||
|
<p>
|
||||||
|
在浏览器中打开授权页面,输入上方验证码并登录 Factory / Droid
|
||||||
|
账户,最后点击允许授权。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤3: 输入授权结果 -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-cyan-300 bg-white/80 p-4 dark:border-cyan-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-cyan-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-cyan-900 dark:text-cyan-200">
|
||||||
|
完成授权后点击下方“完成授权”按钮,系统会自动获取访问令牌。
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
若提示授权仍在等待确认,请稍候片刻后系统会自动重试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
@@ -486,7 +650,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import { useAccountsStore } from '@/stores/accounts'
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
|
|
||||||
@@ -512,14 +676,57 @@ const authUrl = ref('')
|
|||||||
const authCode = ref('')
|
const authCode = ref('')
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
const sessionId = ref('') // 保存sessionId用于后续交换
|
const sessionId = ref('') // 保存sessionId用于后续交换
|
||||||
|
const userCode = ref('')
|
||||||
|
const verificationUri = ref('')
|
||||||
|
const verificationUriComplete = ref('')
|
||||||
|
const remainingSeconds = ref(0)
|
||||||
|
let countdownTimer = null
|
||||||
|
|
||||||
// 计算是否可以交换code
|
// 计算是否可以交换code
|
||||||
const canExchange = computed(() => {
|
const canExchange = computed(() => {
|
||||||
|
if (props.platform === 'droid') {
|
||||||
|
return !!sessionId.value
|
||||||
|
}
|
||||||
return authUrl.value && authCode.value.trim()
|
return authUrl.value && authCode.value.trim()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const formattedCountdown = computed(() => {
|
||||||
|
if (!remainingSeconds.value || remainingSeconds.value <= 0) {
|
||||||
|
return '00:00'
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(remainingSeconds.value / 60)
|
||||||
|
const seconds = remainingSeconds.value % 60
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const startCountdown = (seconds) => {
|
||||||
|
stopCountdown()
|
||||||
|
if (!seconds || seconds <= 0) {
|
||||||
|
remainingSeconds.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingSeconds.value = Math.floor(seconds)
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
if (remainingSeconds.value <= 1) {
|
||||||
|
remainingSeconds.value = 0
|
||||||
|
stopCountdown()
|
||||||
|
} else {
|
||||||
|
remainingSeconds.value -= 1
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopCountdown = () => {
|
||||||
|
if (countdownTimer) {
|
||||||
|
clearInterval(countdownTimer)
|
||||||
|
countdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 监听授权码输入,自动提取URL中的code参数
|
// 监听授权码输入,自动提取URL中的code参数
|
||||||
watch(authCode, (newValue) => {
|
watch(authCode, (newValue) => {
|
||||||
|
if (props.platform === 'droid') return
|
||||||
if (!newValue || typeof newValue !== 'string') return
|
if (!newValue || typeof newValue !== 'string') return
|
||||||
|
|
||||||
const trimmedValue = newValue.trim()
|
const trimmedValue = newValue.trim()
|
||||||
@@ -579,6 +786,15 @@ watch(authCode, (newValue) => {
|
|||||||
|
|
||||||
// 生成授权URL
|
// 生成授权URL
|
||||||
const generateAuthUrl = async () => {
|
const generateAuthUrl = async () => {
|
||||||
|
stopCountdown()
|
||||||
|
authUrl.value = ''
|
||||||
|
authCode.value = ''
|
||||||
|
userCode.value = ''
|
||||||
|
verificationUri.value = ''
|
||||||
|
verificationUriComplete.value = ''
|
||||||
|
remainingSeconds.value = 0
|
||||||
|
sessionId.value = ''
|
||||||
|
copied.value = false
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const proxyConfig = props.proxy?.enabled
|
const proxyConfig = props.proxy?.enabled
|
||||||
@@ -605,6 +821,14 @@ const generateAuthUrl = async () => {
|
|||||||
const result = await accountsStore.generateOpenAIAuthUrl(proxyConfig)
|
const result = await accountsStore.generateOpenAIAuthUrl(proxyConfig)
|
||||||
authUrl.value = result.authUrl
|
authUrl.value = result.authUrl
|
||||||
sessionId.value = result.sessionId
|
sessionId.value = result.sessionId
|
||||||
|
} else if (props.platform === 'droid') {
|
||||||
|
const result = await accountsStore.generateDroidAuthUrl(proxyConfig)
|
||||||
|
authUrl.value = result.verificationUriComplete || result.verificationUri
|
||||||
|
verificationUri.value = result.verificationUri
|
||||||
|
verificationUriComplete.value = result.verificationUriComplete || result.verificationUri
|
||||||
|
userCode.value = result.userCode
|
||||||
|
startCountdown(result.expiresIn || 300)
|
||||||
|
sessionId.value = result.sessionId
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message || '生成授权链接失败', 'error')
|
showToast(error.message || '生成授权链接失败', 'error')
|
||||||
@@ -615,13 +839,23 @@ const generateAuthUrl = async () => {
|
|||||||
|
|
||||||
// 重新生成授权URL
|
// 重新生成授权URL
|
||||||
const regenerateAuthUrl = () => {
|
const regenerateAuthUrl = () => {
|
||||||
|
stopCountdown()
|
||||||
authUrl.value = ''
|
authUrl.value = ''
|
||||||
authCode.value = ''
|
authCode.value = ''
|
||||||
|
userCode.value = ''
|
||||||
|
verificationUri.value = ''
|
||||||
|
verificationUriComplete.value = ''
|
||||||
|
remainingSeconds.value = 0
|
||||||
|
sessionId.value = ''
|
||||||
generateAuthUrl()
|
generateAuthUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制授权URL
|
// 复制授权URL
|
||||||
const copyAuthUrl = async () => {
|
const copyAuthUrl = async () => {
|
||||||
|
if (!authUrl.value) {
|
||||||
|
showToast('请先生成授权链接', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(authUrl.value)
|
await navigator.clipboard.writeText(authUrl.value)
|
||||||
copied.value = true
|
copied.value = true
|
||||||
@@ -645,6 +879,33 @@ const copyAuthUrl = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyUserCode = async () => {
|
||||||
|
if (!userCode.value) {
|
||||||
|
showToast('请先生成授权验证码', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(userCode.value)
|
||||||
|
showToast('验证码已复制', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.value = userCode.value
|
||||||
|
document.body.appendChild(input)
|
||||||
|
input.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(input)
|
||||||
|
showToast('验证码已复制', 'success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openVerificationPage = () => {
|
||||||
|
if (verificationUriComplete.value) {
|
||||||
|
window.open(verificationUriComplete.value, '_blank', 'noopener')
|
||||||
|
} else if (verificationUri.value) {
|
||||||
|
window.open(verificationUri.value, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 交换授权码
|
// 交换授权码
|
||||||
const exchangeCode = async () => {
|
const exchangeCode = async () => {
|
||||||
if (!canExchange.value) return
|
if (!canExchange.value) return
|
||||||
@@ -671,6 +932,10 @@ const exchangeCode = async () => {
|
|||||||
code: authCode.value.trim(),
|
code: authCode.value.trim(),
|
||||||
sessionId: sessionId.value
|
sessionId: sessionId.value
|
||||||
}
|
}
|
||||||
|
} else if (props.platform === 'droid') {
|
||||||
|
data = {
|
||||||
|
sessionId: sessionId.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加代理配置(如果启用)
|
// 添加代理配置(如果启用)
|
||||||
@@ -691,6 +956,21 @@ const exchangeCode = async () => {
|
|||||||
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
||||||
} else if (props.platform === 'openai') {
|
} else if (props.platform === 'openai') {
|
||||||
tokenInfo = await accountsStore.exchangeOpenAICode(data)
|
tokenInfo = await accountsStore.exchangeOpenAICode(data)
|
||||||
|
} else if (props.platform === 'droid') {
|
||||||
|
const response = await accountsStore.exchangeDroidCode(data)
|
||||||
|
if (!response.success) {
|
||||||
|
if (response.pending) {
|
||||||
|
const message = response.message || '授权尚未完成,请在浏览器确认后稍候再次尝试。'
|
||||||
|
showToast(message, 'info')
|
||||||
|
if (typeof response.expiresIn === 'number' && response.expiresIn >= 0) {
|
||||||
|
startCountdown(response.expiresIn)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(response.message || '授权失败,请重试')
|
||||||
|
}
|
||||||
|
tokenInfo = response.data
|
||||||
|
stopCountdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('success', tokenInfo)
|
emit('success', tokenInfo)
|
||||||
@@ -700,4 +980,8 @@ const exchangeCode = async () => {
|
|||||||
exchanging.value = false
|
exchanging.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopCountdown()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -311,6 +311,10 @@
|
|||||||
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
|
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
|
||||||
<span class="text-sm text-gray-700">仅 OpenAI</span>
|
<span class="text-sm text-gray-700">仅 OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input v-model="form.permissions" class="mr-2" type="radio" value="droid" />
|
||||||
|
<span class="text-sm text-gray-700">仅 Droid</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -345,7 +349,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
:disabled="form.permissions && !['all', 'claude'].includes(form.permissions)"
|
||||||
>
|
>
|
||||||
<option value="">不修改</option>
|
<option value="">不修改</option>
|
||||||
<option value="SHARED_POOL">使用共享账号池</option>
|
<option value="SHARED_POOL">使用共享账号池</option>
|
||||||
@@ -380,7 +384,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
:disabled="form.permissions && !['all', 'gemini'].includes(form.permissions)"
|
||||||
>
|
>
|
||||||
<option value="">不修改</option>
|
<option value="">不修改</option>
|
||||||
<option value="SHARED_POOL">使用共享账号池</option>
|
<option value="SHARED_POOL">使用共享账号池</option>
|
||||||
@@ -411,7 +415,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="form.openaiAccountId"
|
v-model="form.openaiAccountId"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
:disabled="form.permissions && !['all', 'openai'].includes(form.permissions)"
|
||||||
>
|
>
|
||||||
<option value="">不修改</option>
|
<option value="">不修改</option>
|
||||||
<option value="SHARED_POOL">使用共享账号池</option>
|
<option value="SHARED_POOL">使用共享账号池</option>
|
||||||
@@ -442,7 +446,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="form.bedrockAccountId"
|
v-model="form.bedrockAccountId"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
:disabled="form.permissions && !['all', 'openai'].includes(form.permissions)"
|
||||||
>
|
>
|
||||||
<option value="">不修改</option>
|
<option value="">不修改</option>
|
||||||
<option value="SHARED_POOL">使用共享账号池</option>
|
<option value="SHARED_POOL">使用共享账号池</option>
|
||||||
@@ -457,6 +461,37 @@
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>Droid 专属账号</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="form.droidAccountId"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||||
|
:disabled="form.permissions && !['all', 'droid'].includes(form.permissions)"
|
||||||
|
>
|
||||||
|
<option value="">不修改</option>
|
||||||
|
<option value="SHARED_POOL">使用共享账号池</option>
|
||||||
|
<optgroup v-if="localAccounts.droidGroups.length > 0" label="账号分组">
|
||||||
|
<option
|
||||||
|
v-for="group in localAccounts.droidGroups"
|
||||||
|
:key="group.id"
|
||||||
|
:value="`group:${group.id}`"
|
||||||
|
>
|
||||||
|
分组 - {{ group.name }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup v-if="localAccounts.droid.length > 0" label="专属账号">
|
||||||
|
<option
|
||||||
|
v-for="account in localAccounts.droid"
|
||||||
|
:key="account.id"
|
||||||
|
:value="account.id"
|
||||||
|
>
|
||||||
|
{{ account.name }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -497,7 +532,17 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ claude: [], gemini: [], openai: [], bedrock: [] })
|
default: () => ({
|
||||||
|
claude: [],
|
||||||
|
gemini: [],
|
||||||
|
openai: [],
|
||||||
|
bedrock: [],
|
||||||
|
droid: [],
|
||||||
|
claudeGroups: [],
|
||||||
|
geminiGroups: [],
|
||||||
|
openaiGroups: [],
|
||||||
|
droidGroups: []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -511,9 +556,11 @@ const localAccounts = ref({
|
|||||||
gemini: [],
|
gemini: [],
|
||||||
openai: [],
|
openai: [],
|
||||||
bedrock: [],
|
bedrock: [],
|
||||||
|
droid: [],
|
||||||
claudeGroups: [],
|
claudeGroups: [],
|
||||||
geminiGroups: [],
|
geminiGroups: [],
|
||||||
openaiGroups: []
|
openaiGroups: [],
|
||||||
|
droidGroups: []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 标签相关
|
// 标签相关
|
||||||
@@ -542,6 +589,7 @@ const form = reactive({
|
|||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
openaiAccountId: '',
|
openaiAccountId: '',
|
||||||
bedrockAccountId: '',
|
bedrockAccountId: '',
|
||||||
|
droidAccountId: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
isActive: null // null表示不修改
|
isActive: null // null表示不修改
|
||||||
})
|
})
|
||||||
@@ -571,13 +619,21 @@ const removeTag = (index) => {
|
|||||||
const refreshAccounts = async () => {
|
const refreshAccounts = async () => {
|
||||||
accountsLoading.value = true
|
accountsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
const [
|
||||||
await Promise.all([
|
claudeData,
|
||||||
|
claudeConsoleData,
|
||||||
|
geminiData,
|
||||||
|
openaiData,
|
||||||
|
bedrockData,
|
||||||
|
droidData,
|
||||||
|
groupsData
|
||||||
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
apiClient.get('/admin/claude-accounts'),
|
||||||
apiClient.get('/admin/claude-console-accounts'),
|
apiClient.get('/admin/claude-console-accounts'),
|
||||||
apiClient.get('/admin/gemini-accounts'),
|
apiClient.get('/admin/gemini-accounts'),
|
||||||
apiClient.get('/admin/openai-accounts'),
|
apiClient.get('/admin/openai-accounts'),
|
||||||
apiClient.get('/admin/bedrock-accounts'),
|
apiClient.get('/admin/bedrock-accounts'),
|
||||||
|
apiClient.get('/admin/droid-accounts'),
|
||||||
apiClient.get('/admin/account-groups')
|
apiClient.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -627,12 +683,21 @@ const refreshAccounts = async () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (droidData.success) {
|
||||||
|
localAccounts.value.droid = (droidData.data || []).map((account) => ({
|
||||||
|
...account,
|
||||||
|
platform: 'droid',
|
||||||
|
isDedicated: account.accountType === 'dedicated'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// 处理分组数据
|
// 处理分组数据
|
||||||
if (groupsData.success) {
|
if (groupsData.success) {
|
||||||
const allGroups = groupsData.data || []
|
const allGroups = groupsData.data || []
|
||||||
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
||||||
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
||||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||||
|
localAccounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid')
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('账号列表已刷新', 'success')
|
showToast('账号列表已刷新', 'success')
|
||||||
@@ -720,6 +785,14 @@ const batchUpdateApiKeys = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.droidAccountId !== '') {
|
||||||
|
if (form.droidAccountId === 'SHARED_POOL') {
|
||||||
|
updates.droidAccountId = null
|
||||||
|
} else {
|
||||||
|
updates.droidAccountId = form.droidAccountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 激活状态
|
// 激活状态
|
||||||
if (form.isActive !== null) {
|
if (form.isActive !== null) {
|
||||||
updates.isActive = form.isActive
|
updates.isActive = form.isActive
|
||||||
@@ -774,9 +847,11 @@ onMounted(async () => {
|
|||||||
gemini: props.accounts.gemini || [],
|
gemini: props.accounts.gemini || [],
|
||||||
openai: props.accounts.openai || [],
|
openai: props.accounts.openai || [],
|
||||||
bedrock: props.accounts.bedrock || [],
|
bedrock: props.accounts.bedrock || [],
|
||||||
|
droid: props.accounts.droid || [],
|
||||||
claudeGroups: props.accounts.claudeGroups || [],
|
claudeGroups: props.accounts.claudeGroups || [],
|
||||||
geminiGroups: props.accounts.geminiGroups || [],
|
geminiGroups: props.accounts.geminiGroups || [],
|
||||||
openaiGroups: props.accounts.openaiGroups || []
|
openaiGroups: props.accounts.openaiGroups || [],
|
||||||
|
droidGroups: props.accounts.droidGroups || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -616,6 +616,15 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.permissions"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="droid"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
控制此 API Key 可以访问哪些服务
|
控制此 API Key 可以访问哪些服务
|
||||||
@@ -653,7 +662,7 @@
|
|||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
:accounts="localAccounts.claude"
|
:accounts="localAccounts.claude"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
||||||
:groups="localAccounts.claudeGroups"
|
:groups="localAccounts.claudeGroups"
|
||||||
placeholder="请选择Claude账号"
|
placeholder="请选择Claude账号"
|
||||||
platform="claude"
|
platform="claude"
|
||||||
@@ -667,7 +676,7 @@
|
|||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
:accounts="localAccounts.gemini"
|
:accounts="localAccounts.gemini"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
||||||
:groups="localAccounts.geminiGroups"
|
:groups="localAccounts.geminiGroups"
|
||||||
placeholder="请选择Gemini账号"
|
placeholder="请选择Gemini账号"
|
||||||
platform="gemini"
|
platform="gemini"
|
||||||
@@ -681,7 +690,7 @@
|
|||||||
v-model="form.openaiAccountId"
|
v-model="form.openaiAccountId"
|
||||||
:accounts="localAccounts.openai"
|
:accounts="localAccounts.openai"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||||
:groups="localAccounts.openaiGroups"
|
:groups="localAccounts.openaiGroups"
|
||||||
placeholder="请选择OpenAI账号"
|
placeholder="请选择OpenAI账号"
|
||||||
platform="openai"
|
platform="openai"
|
||||||
@@ -695,12 +704,26 @@
|
|||||||
v-model="form.bedrockAccountId"
|
v-model="form.bedrockAccountId"
|
||||||
:accounts="localAccounts.bedrock"
|
:accounts="localAccounts.bedrock"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||||
:groups="[]"
|
:groups="[]"
|
||||||
placeholder="请选择Bedrock账号"
|
placeholder="请选择Bedrock账号"
|
||||||
platform="bedrock"
|
platform="bedrock"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>Droid 专属账号</label
|
||||||
|
>
|
||||||
|
<AccountSelector
|
||||||
|
v-model="form.droidAccountId"
|
||||||
|
:accounts="localAccounts.droid"
|
||||||
|
default-option-text="使用共享账号池"
|
||||||
|
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
||||||
|
:groups="localAccounts.droidGroups"
|
||||||
|
placeholder="请选择Droid账号"
|
||||||
|
platform="droid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池
|
选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池
|
||||||
@@ -875,7 +898,17 @@ import AccountSelector from '@/components/common/AccountSelector.vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
accounts: {
|
accounts: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ claude: [], gemini: [] })
|
default: () => ({
|
||||||
|
claude: [],
|
||||||
|
gemini: [],
|
||||||
|
openai: [],
|
||||||
|
bedrock: [],
|
||||||
|
droid: [],
|
||||||
|
claudeGroups: [],
|
||||||
|
geminiGroups: [],
|
||||||
|
openaiGroups: [],
|
||||||
|
droidGroups: []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -889,10 +922,12 @@ const localAccounts = ref({
|
|||||||
claude: [],
|
claude: [],
|
||||||
gemini: [],
|
gemini: [],
|
||||||
openai: [],
|
openai: [],
|
||||||
bedrock: [], // 添加 Bedrock 账号列表
|
bedrock: [],
|
||||||
|
droid: [],
|
||||||
claudeGroups: [],
|
claudeGroups: [],
|
||||||
geminiGroups: [],
|
geminiGroups: [],
|
||||||
openaiGroups: []
|
openaiGroups: [],
|
||||||
|
droidGroups: []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证状态
|
// 表单验证状态
|
||||||
@@ -935,7 +970,8 @@ const form = reactive({
|
|||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
openaiAccountId: '',
|
openaiAccountId: '',
|
||||||
bedrockAccountId: '', // 添加 Bedrock 账号ID
|
bedrockAccountId: '',
|
||||||
|
droidAccountId: '',
|
||||||
enableModelRestriction: false,
|
enableModelRestriction: false,
|
||||||
restrictedModels: [],
|
restrictedModels: [],
|
||||||
modelInput: '',
|
modelInput: '',
|
||||||
@@ -973,10 +1009,15 @@ onMounted(async () => {
|
|||||||
claude: props.accounts.claude || [],
|
claude: props.accounts.claude || [],
|
||||||
gemini: props.accounts.gemini || [],
|
gemini: props.accounts.gemini || [],
|
||||||
openai: openaiAccounts,
|
openai: openaiAccounts,
|
||||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
bedrock: props.accounts.bedrock || [],
|
||||||
|
droid: (props.accounts.droid || []).map((account) => ({
|
||||||
|
...account,
|
||||||
|
platform: 'droid'
|
||||||
|
})),
|
||||||
claudeGroups: props.accounts.claudeGroups || [],
|
claudeGroups: props.accounts.claudeGroups || [],
|
||||||
geminiGroups: props.accounts.geminiGroups || [],
|
geminiGroups: props.accounts.geminiGroups || [],
|
||||||
openaiGroups: props.accounts.openaiGroups || []
|
openaiGroups: props.accounts.openaiGroups || [],
|
||||||
|
droidGroups: props.accounts.droidGroups || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,6 +1036,7 @@ const refreshAccounts = async () => {
|
|||||||
openaiData,
|
openaiData,
|
||||||
openaiResponsesData,
|
openaiResponsesData,
|
||||||
bedrockData,
|
bedrockData,
|
||||||
|
droidData,
|
||||||
groupsData
|
groupsData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
apiClient.get('/admin/claude-accounts'),
|
||||||
@@ -1002,7 +1044,8 @@ const refreshAccounts = async () => {
|
|||||||
apiClient.get('/admin/gemini-accounts'),
|
apiClient.get('/admin/gemini-accounts'),
|
||||||
apiClient.get('/admin/openai-accounts'),
|
apiClient.get('/admin/openai-accounts'),
|
||||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
apiClient.get('/admin/bedrock-accounts'),
|
||||||
|
apiClient.get('/admin/droid-accounts'),
|
||||||
apiClient.get('/admin/account-groups')
|
apiClient.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1070,12 +1113,21 @@ const refreshAccounts = async () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (droidData.success) {
|
||||||
|
localAccounts.value.droid = (droidData.data || []).map((account) => ({
|
||||||
|
...account,
|
||||||
|
platform: 'droid',
|
||||||
|
isDedicated: account.accountType === 'dedicated'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// 处理分组数据
|
// 处理分组数据
|
||||||
if (groupsData.success) {
|
if (groupsData.success) {
|
||||||
const allGroups = groupsData.data || []
|
const allGroups = groupsData.data || []
|
||||||
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
||||||
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
||||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||||
|
localAccounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid')
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('账号列表已刷新', 'success')
|
showToast('账号列表已刷新', 'success')
|
||||||
@@ -1346,6 +1398,9 @@ const createApiKey = async () => {
|
|||||||
if (form.bedrockAccountId) {
|
if (form.bedrockAccountId) {
|
||||||
baseData.bedrockAccountId = form.bedrockAccountId
|
baseData.bedrockAccountId = form.bedrockAccountId
|
||||||
}
|
}
|
||||||
|
if (form.droidAccountId) {
|
||||||
|
baseData.droidAccountId = form.droidAccountId
|
||||||
|
}
|
||||||
|
|
||||||
if (form.createType === 'single') {
|
if (form.createType === 'single') {
|
||||||
// 单个创建
|
// 单个创建
|
||||||
|
|||||||
@@ -449,6 +449,15 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.permissions"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="droid"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
控制此 API Key 可以访问哪些服务
|
控制此 API Key 可以访问哪些服务
|
||||||
@@ -486,7 +495,7 @@
|
|||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
:accounts="localAccounts.claude"
|
:accounts="localAccounts.claude"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
||||||
:groups="localAccounts.claudeGroups"
|
:groups="localAccounts.claudeGroups"
|
||||||
placeholder="请选择Claude账号"
|
placeholder="请选择Claude账号"
|
||||||
platform="claude"
|
platform="claude"
|
||||||
@@ -500,7 +509,7 @@
|
|||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
:accounts="localAccounts.gemini"
|
:accounts="localAccounts.gemini"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
||||||
:groups="localAccounts.geminiGroups"
|
:groups="localAccounts.geminiGroups"
|
||||||
placeholder="请选择Gemini账号"
|
placeholder="请选择Gemini账号"
|
||||||
platform="gemini"
|
platform="gemini"
|
||||||
@@ -514,7 +523,7 @@
|
|||||||
v-model="form.openaiAccountId"
|
v-model="form.openaiAccountId"
|
||||||
:accounts="localAccounts.openai"
|
:accounts="localAccounts.openai"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||||
:groups="localAccounts.openaiGroups"
|
:groups="localAccounts.openaiGroups"
|
||||||
placeholder="请选择OpenAI账号"
|
placeholder="请选择OpenAI账号"
|
||||||
platform="openai"
|
platform="openai"
|
||||||
@@ -528,12 +537,26 @@
|
|||||||
v-model="form.bedrockAccountId"
|
v-model="form.bedrockAccountId"
|
||||||
:accounts="localAccounts.bedrock"
|
:accounts="localAccounts.bedrock"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
|
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
||||||
:groups="[]"
|
:groups="[]"
|
||||||
placeholder="请选择Bedrock账号"
|
placeholder="请选择Bedrock账号"
|
||||||
platform="bedrock"
|
platform="bedrock"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>Droid 专属账号</label
|
||||||
|
>
|
||||||
|
<AccountSelector
|
||||||
|
v-model="form.droidAccountId"
|
||||||
|
:accounts="localAccounts.droid"
|
||||||
|
default-option-text="使用共享账号池"
|
||||||
|
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
||||||
|
:groups="localAccounts.droidGroups"
|
||||||
|
placeholder="请选择Droid账号"
|
||||||
|
platform="droid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
修改绑定账号将影响此API Key的请求路由
|
修改绑定账号将影响此API Key的请求路由
|
||||||
@@ -717,7 +740,18 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ claude: [], gemini: [] })
|
default: () => ({
|
||||||
|
claude: [],
|
||||||
|
gemini: [],
|
||||||
|
openai: [],
|
||||||
|
bedrock: [],
|
||||||
|
droid: [],
|
||||||
|
claudeGroups: [],
|
||||||
|
geminiGroups: [],
|
||||||
|
openaiGroups: [],
|
||||||
|
droidGroups: [],
|
||||||
|
openaiResponses: []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -732,10 +766,12 @@ const localAccounts = ref({
|
|||||||
claude: [],
|
claude: [],
|
||||||
gemini: [],
|
gemini: [],
|
||||||
openai: [],
|
openai: [],
|
||||||
bedrock: [], // 添加 Bedrock 账号列表
|
bedrock: [],
|
||||||
|
droid: [],
|
||||||
claudeGroups: [],
|
claudeGroups: [],
|
||||||
geminiGroups: [],
|
geminiGroups: [],
|
||||||
openaiGroups: []
|
openaiGroups: [],
|
||||||
|
droidGroups: []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 支持的客户端列表
|
// 支持的客户端列表
|
||||||
@@ -768,7 +804,8 @@ const form = reactive({
|
|||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
openaiAccountId: '',
|
openaiAccountId: '',
|
||||||
bedrockAccountId: '', // 添加 Bedrock 账号ID
|
bedrockAccountId: '',
|
||||||
|
droidAccountId: '',
|
||||||
enableModelRestriction: false,
|
enableModelRestriction: false,
|
||||||
restrictedModels: [],
|
restrictedModels: [],
|
||||||
modelInput: '',
|
modelInput: '',
|
||||||
@@ -930,6 +967,12 @@ const updateApiKey = async () => {
|
|||||||
data.bedrockAccountId = null
|
data.bedrockAccountId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.droidAccountId) {
|
||||||
|
data.droidAccountId = form.droidAccountId
|
||||||
|
} else {
|
||||||
|
data.droidAccountId = null
|
||||||
|
}
|
||||||
|
|
||||||
// 模型限制 - 始终提交这些字段
|
// 模型限制 - 始终提交这些字段
|
||||||
data.enableModelRestriction = form.enableModelRestriction
|
data.enableModelRestriction = form.enableModelRestriction
|
||||||
data.restrictedModels = form.restrictedModels
|
data.restrictedModels = form.restrictedModels
|
||||||
@@ -972,14 +1015,16 @@ const refreshAccounts = async () => {
|
|||||||
openaiData,
|
openaiData,
|
||||||
openaiResponsesData,
|
openaiResponsesData,
|
||||||
bedrockData,
|
bedrockData,
|
||||||
|
droidData,
|
||||||
groupsData
|
groupsData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
apiClient.get('/admin/claude-accounts'),
|
||||||
apiClient.get('/admin/claude-console-accounts'),
|
apiClient.get('/admin/claude-console-accounts'),
|
||||||
apiClient.get('/admin/gemini-accounts'),
|
apiClient.get('/admin/gemini-accounts'),
|
||||||
apiClient.get('/admin/openai-accounts'),
|
apiClient.get('/admin/openai-accounts'),
|
||||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
apiClient.get('/admin/openai-responses-accounts'),
|
||||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
apiClient.get('/admin/bedrock-accounts'),
|
||||||
|
apiClient.get('/admin/droid-accounts'),
|
||||||
apiClient.get('/admin/account-groups')
|
apiClient.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1047,12 +1092,21 @@ const refreshAccounts = async () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (droidData.success) {
|
||||||
|
localAccounts.value.droid = (droidData.data || []).map((account) => ({
|
||||||
|
...account,
|
||||||
|
platform: 'droid',
|
||||||
|
isDedicated: account.accountType === 'dedicated'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// 处理分组数据
|
// 处理分组数据
|
||||||
if (groupsData.success) {
|
if (groupsData.success) {
|
||||||
const allGroups = groupsData.data || []
|
const allGroups = groupsData.data || []
|
||||||
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
||||||
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
||||||
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||||
|
localAccounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid')
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('账号列表已刷新', 'success')
|
showToast('账号列表已刷新', 'success')
|
||||||
@@ -1128,10 +1182,15 @@ onMounted(async () => {
|
|||||||
claude: props.accounts.claude || [],
|
claude: props.accounts.claude || [],
|
||||||
gemini: props.accounts.gemini || [],
|
gemini: props.accounts.gemini || [],
|
||||||
openai: openaiAccounts,
|
openai: openaiAccounts,
|
||||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
bedrock: props.accounts.bedrock || [],
|
||||||
|
droid: (props.accounts.droid || []).map((account) => ({
|
||||||
|
...account,
|
||||||
|
platform: 'droid'
|
||||||
|
})),
|
||||||
claudeGroups: props.accounts.claudeGroups || [],
|
claudeGroups: props.accounts.claudeGroups || [],
|
||||||
geminiGroups: props.accounts.geminiGroups || [],
|
geminiGroups: props.accounts.geminiGroups || [],
|
||||||
openaiGroups: props.accounts.openaiGroups || []
|
openaiGroups: props.accounts.openaiGroups || [],
|
||||||
|
droidGroups: props.accounts.droidGroups || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,7 +1227,8 @@ onMounted(async () => {
|
|||||||
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
|
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
|
||||||
form.openaiAccountId = props.apiKey.openaiAccountId || ''
|
form.openaiAccountId = props.apiKey.openaiAccountId || ''
|
||||||
|
|
||||||
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
|
form.bedrockAccountId = props.apiKey.bedrockAccountId || ''
|
||||||
|
form.droidAccountId = props.apiKey.droidAccountId || ''
|
||||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||||
form.allowedClients = props.apiKey.allowedClients || []
|
form.allowedClients = props.apiKey.allowedClients || []
|
||||||
form.tags = props.apiKey.tags || []
|
form.tags = props.apiKey.tags || []
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
点击眼睛图标切换显示模式,使用下方按钮复制完整 API Key
|
点击眼睛图标切换显示模式,使用下方按钮复制环境变量配置
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
@click="copyApiKey"
|
@click="copyApiKey"
|
||||||
>
|
>
|
||||||
<i class="fas fa-copy" />
|
<i class="fas fa-copy" />
|
||||||
复制 API Key
|
复制配置信息
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -134,6 +134,41 @@ const emit = defineEmits(['close'])
|
|||||||
|
|
||||||
const showFullKey = ref(false)
|
const showFullKey = ref(false)
|
||||||
|
|
||||||
|
// 获取 API Base URL 前缀
|
||||||
|
const getBaseUrlPrefix = () => {
|
||||||
|
// 优先使用环境变量配置的自定义前缀
|
||||||
|
const customPrefix = import.meta.env.VITE_API_BASE_PREFIX
|
||||||
|
if (customPrefix) {
|
||||||
|
// 去除末尾的斜杠
|
||||||
|
return customPrefix.replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用当前浏览器访问地址
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const protocol = window.location.protocol // http: 或 https:
|
||||||
|
const host = window.location.host // 域名和端口
|
||||||
|
// 提取协议和主机部分,去除路径
|
||||||
|
let origin = protocol + '//' + host
|
||||||
|
|
||||||
|
// 如果当前URL包含路径,只取协议+主机部分
|
||||||
|
const currentUrl = window.location.href
|
||||||
|
const pathStart = currentUrl.indexOf('/', 8) // 跳过 http:// 或 https://
|
||||||
|
if (pathStart !== -1) {
|
||||||
|
origin = currentUrl.substring(0, pathStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端渲染或其他情况的回退
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算完整的 API Base URL
|
||||||
|
const currentBaseUrl = computed(() => {
|
||||||
|
return getBaseUrlPrefix() + '/api'
|
||||||
|
})
|
||||||
|
|
||||||
// 切换密钥可见性
|
// 切换密钥可见性
|
||||||
const toggleKeyVisibility = () => {
|
const toggleKeyVisibility = () => {
|
||||||
showFullKey.value = !showFullKey.value
|
showFullKey.value = !showFullKey.value
|
||||||
@@ -155,7 +190,7 @@ const getDisplayedApiKey = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制 API Key
|
// 复制配置信息(环境变量格式)
|
||||||
const copyApiKey = async () => {
|
const copyApiKey = async () => {
|
||||||
const key = props.apiKey.apiKey || props.apiKey.key || ''
|
const key = props.apiKey.apiKey || props.apiKey.key || ''
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -163,19 +198,23 @@ const copyApiKey = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建环境变量配置格式
|
||||||
|
const configText = `ANTHROPIC_BASE_URL="${currentBaseUrl.value}"
|
||||||
|
ANTHROPIC_AUTH_TOKEN="${key}"`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(key)
|
await navigator.clipboard.writeText(configText)
|
||||||
showToast('API Key 已复制到剪贴板', 'success')
|
showToast('配置信息已复制到剪贴板', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Failed to copy:', error)
|
// console.error('Failed to copy:', error)
|
||||||
// 降级方案:创建一个临时文本区域
|
// 降级方案:创建一个临时文本区域
|
||||||
const textArea = document.createElement('textarea')
|
const textArea = document.createElement('textarea')
|
||||||
textArea.value = key
|
textArea.value = configText
|
||||||
document.body.appendChild(textArea)
|
document.body.appendChild(textArea)
|
||||||
textArea.select()
|
textArea.select()
|
||||||
try {
|
try {
|
||||||
document.execCommand('copy')
|
document.execCommand('copy')
|
||||||
showToast('API Key 已复制到剪贴板', 'success')
|
showToast('配置信息已复制到剪贴板', 'success')
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
showToast('复制失败,请手动复制', 'error')
|
showToast('复制失败,请手动复制', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -104,6 +104,8 @@
|
|||||||
? 'Claude OAuth 专属账号'
|
? 'Claude OAuth 专属账号'
|
||||||
: platform === 'openai'
|
: platform === 'openai'
|
||||||
? 'OpenAI 专属账号'
|
? 'OpenAI 专属账号'
|
||||||
|
: platform === 'droid'
|
||||||
|
? 'Droid 专属账号'
|
||||||
: 'OAuth 专属账号'
|
: 'OAuth 专属账号'
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +243,7 @@ const props = defineProps({
|
|||||||
platform: {
|
platform: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value)
|
validator: (value) => ['claude', 'gemini', 'openai', 'bedrock', 'droid'].includes(value)
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -383,6 +385,8 @@ const filteredOAuthAccounts = computed(() => {
|
|||||||
} else if (props.platform === 'openai') {
|
} else if (props.platform === 'openai') {
|
||||||
// 对于 OpenAI,只显示 openai 类型的账号
|
// 对于 OpenAI,只显示 openai 类型的账号
|
||||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
|
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
|
||||||
|
} else if (props.platform === 'droid') {
|
||||||
|
accounts = sortedAccounts.value.filter((a) => a.platform === 'droid')
|
||||||
} else {
|
} else {
|
||||||
// 其他平台显示所有非特殊类型的账号
|
// 其他平台显示所有非特殊类型的账号
|
||||||
accounts = sortedAccounts.value.filter(
|
accounts = sortedAccounts.value.filter(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
const openaiAccounts = ref([])
|
const openaiAccounts = ref([])
|
||||||
const azureOpenaiAccounts = ref([])
|
const azureOpenaiAccounts = ref([])
|
||||||
const openaiResponsesAccounts = ref([])
|
const openaiResponsesAccounts = ref([])
|
||||||
|
const droidAccounts = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const sortBy = ref('')
|
const sortBy = ref('')
|
||||||
@@ -151,6 +152,25 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取Droid账户列表
|
||||||
|
const fetchDroidAccounts = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/droid-accounts')
|
||||||
|
if (response.success) {
|
||||||
|
droidAccounts.value = response.data || []
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取Droid账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取所有账户
|
// 获取所有账户
|
||||||
const fetchAllAccounts = async () => {
|
const fetchAllAccounts = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -163,7 +183,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
fetchGeminiAccounts(),
|
fetchGeminiAccounts(),
|
||||||
fetchOpenAIAccounts(),
|
fetchOpenAIAccounts(),
|
||||||
fetchAzureOpenAIAccounts(),
|
fetchAzureOpenAIAccounts(),
|
||||||
fetchOpenAIResponsesAccounts()
|
fetchOpenAIResponsesAccounts(),
|
||||||
|
fetchDroidAccounts()
|
||||||
])
|
])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message
|
error.value = err.message
|
||||||
@@ -273,6 +294,46 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建Droid账户
|
||||||
|
const createDroidAccount = async (data) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/droid-accounts', data)
|
||||||
|
if (response.success) {
|
||||||
|
await fetchDroidAccounts()
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '创建Droid账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Droid账户
|
||||||
|
const updateDroidAccount = async (id, data) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/admin/droid-accounts/${id}`, data)
|
||||||
|
if (response.success) {
|
||||||
|
await fetchDroidAccounts()
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '更新Droid账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 创建Azure OpenAI账户
|
// 创建Azure OpenAI账户
|
||||||
const createAzureOpenAIAccount = async (data) => {
|
const createAzureOpenAIAccount = async (data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -694,6 +755,22 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成Droid OAuth URL
|
||||||
|
const generateDroidAuthUrl = async (proxyConfig) => {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/droid-accounts/generate-auth-url', proxyConfig)
|
||||||
|
if (response.success) {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '生成授权URL失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 交换OpenAI OAuth Code
|
// 交换OpenAI OAuth Code
|
||||||
const exchangeOpenAICode = async (data) => {
|
const exchangeOpenAICode = async (data) => {
|
||||||
try {
|
try {
|
||||||
@@ -709,6 +786,18 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 交换Droid OAuth Code
|
||||||
|
const exchangeDroidCode = async (data) => {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/droid-accounts/exchange-code', data)
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 排序账户
|
// 排序账户
|
||||||
const sortAccounts = (field) => {
|
const sortAccounts = (field) => {
|
||||||
if (sortBy.value === field) {
|
if (sortBy.value === field) {
|
||||||
@@ -728,6 +817,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
openaiAccounts.value = []
|
openaiAccounts.value = []
|
||||||
azureOpenaiAccounts.value = []
|
azureOpenaiAccounts.value = []
|
||||||
openaiResponsesAccounts.value = []
|
openaiResponsesAccounts.value = []
|
||||||
|
droidAccounts.value = []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
sortBy.value = ''
|
sortBy.value = ''
|
||||||
@@ -743,6 +833,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
openaiAccounts,
|
openaiAccounts,
|
||||||
azureOpenaiAccounts,
|
azureOpenaiAccounts,
|
||||||
openaiResponsesAccounts,
|
openaiResponsesAccounts,
|
||||||
|
droidAccounts,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
sortBy,
|
sortBy,
|
||||||
@@ -756,12 +847,15 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
fetchOpenAIAccounts,
|
fetchOpenAIAccounts,
|
||||||
fetchAzureOpenAIAccounts,
|
fetchAzureOpenAIAccounts,
|
||||||
fetchOpenAIResponsesAccounts,
|
fetchOpenAIResponsesAccounts,
|
||||||
|
fetchDroidAccounts,
|
||||||
fetchAllAccounts,
|
fetchAllAccounts,
|
||||||
createClaudeAccount,
|
createClaudeAccount,
|
||||||
createClaudeConsoleAccount,
|
createClaudeConsoleAccount,
|
||||||
createBedrockAccount,
|
createBedrockAccount,
|
||||||
createGeminiAccount,
|
createGeminiAccount,
|
||||||
createOpenAIAccount,
|
createOpenAIAccount,
|
||||||
|
createDroidAccount,
|
||||||
|
updateDroidAccount,
|
||||||
createAzureOpenAIAccount,
|
createAzureOpenAIAccount,
|
||||||
createOpenAIResponsesAccount,
|
createOpenAIResponsesAccount,
|
||||||
updateClaudeAccount,
|
updateClaudeAccount,
|
||||||
@@ -782,6 +876,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
exchangeGeminiCode,
|
exchangeGeminiCode,
|
||||||
generateOpenAIAuthUrl,
|
generateOpenAIAuthUrl,
|
||||||
exchangeOpenAICode,
|
exchangeOpenAICode,
|
||||||
|
generateDroidAuthUrl,
|
||||||
|
exchangeDroidCode,
|
||||||
sortAccounts,
|
sortAccounts,
|
||||||
reset
|
reset
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,6 +537,17 @@
|
|||||||
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
|
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
|
||||||
<span class="text-xs font-medium text-teal-700 dark:text-teal-300">Relay</span>
|
<span class="text-xs font-medium text-teal-700 dark:text-teal-300">Relay</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="account.platform === 'droid'"
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-cyan-200 bg-gradient-to-r from-cyan-100 to-sky-100 px-2.5 py-1 dark:border-cyan-700 dark:from-cyan-900/20 dark:to-sky-900/20"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot text-xs text-cyan-700 dark:text-cyan-400" />
|
||||||
|
<span class="text-xs font-semibold text-cyan-800 dark:text-cyan-300"
|
||||||
|
>Droid</span
|
||||||
|
>
|
||||||
|
<span class="mx-1 h-4 w-px bg-cyan-300 dark:bg-cyan-600" />
|
||||||
|
<span class="text-xs font-medium text-cyan-700 dark:text-cyan-300">OAuth</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
|
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
|
||||||
@@ -656,7 +667,8 @@
|
|||||||
account.platform === 'openai' ||
|
account.platform === 'openai' ||
|
||||||
account.platform === 'openai-responses' ||
|
account.platform === 'openai-responses' ||
|
||||||
account.platform === 'azure_openai' ||
|
account.platform === 'azure_openai' ||
|
||||||
account.platform === 'ccr'
|
account.platform === 'ccr' ||
|
||||||
|
account.platform === 'droid'
|
||||||
"
|
"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -1108,6 +1120,8 @@
|
|||||||
? 'bg-gradient-to-br from-gray-600 to-gray-700'
|
? 'bg-gradient-to-br from-gray-600 to-gray-700'
|
||||||
: account.platform === 'ccr'
|
: account.platform === 'ccr'
|
||||||
? 'bg-gradient-to-br from-teal-500 to-emerald-600'
|
? 'bg-gradient-to-br from-teal-500 to-emerald-600'
|
||||||
|
: account.platform === 'droid'
|
||||||
|
? 'bg-gradient-to-br from-cyan-500 to-sky-600'
|
||||||
: 'bg-gradient-to-br from-blue-500 to-blue-600'
|
: 'bg-gradient-to-br from-blue-500 to-blue-600'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -1124,6 +1138,8 @@
|
|||||||
? 'fas fa-openai'
|
? 'fas fa-openai'
|
||||||
: account.platform === 'ccr'
|
: account.platform === 'ccr'
|
||||||
? 'fas fa-code-branch'
|
? 'fas fa-code-branch'
|
||||||
|
: account.platform === 'droid'
|
||||||
|
? 'fas fa-robot'
|
||||||
: 'fas fa-robot'
|
: 'fas fa-robot'
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -1696,7 +1712,14 @@ const accountUsageSummary = ref({})
|
|||||||
const accountUsageOverview = ref({})
|
const accountUsageOverview = ref({})
|
||||||
const accountUsageGeneratedAt = ref('')
|
const accountUsageGeneratedAt = ref('')
|
||||||
|
|
||||||
const supportedUsagePlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
|
const supportedUsagePlatforms = [
|
||||||
|
'claude',
|
||||||
|
'claude-console',
|
||||||
|
'openai',
|
||||||
|
'openai-responses',
|
||||||
|
'gemini',
|
||||||
|
'droid'
|
||||||
|
]
|
||||||
|
|
||||||
// 缓存状态标志
|
// 缓存状态标志
|
||||||
const apiKeysLoaded = ref(false)
|
const apiKeysLoaded = ref(false)
|
||||||
@@ -1722,7 +1745,8 @@ const platformOptions = ref([
|
|||||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
||||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
||||||
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
|
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
|
||||||
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }
|
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' },
|
||||||
|
{ value: 'droid', label: 'Droid', icon: 'fa-robot' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const groupOptions = computed(() => {
|
const groupOptions = computed(() => {
|
||||||
@@ -1733,13 +1757,15 @@ const groupOptions = computed(() => {
|
|||||||
accountGroups.value.forEach((group) => {
|
accountGroups.value.forEach((group) => {
|
||||||
options.push({
|
options.push({
|
||||||
value: group.id,
|
value: group.id,
|
||||||
label: `${group.name} (${group.platform === 'claude' ? 'Claude' : group.platform === 'gemini' ? 'Gemini' : 'OpenAI'})`,
|
label: `${group.name} (${group.platform === 'claude' ? 'Claude' : group.platform === 'gemini' ? 'Gemini' : group.platform === 'openai' ? 'OpenAI' : 'Droid'})`,
|
||||||
icon:
|
icon:
|
||||||
group.platform === 'claude'
|
group.platform === 'claude'
|
||||||
? 'fa-brain'
|
? 'fa-brain'
|
||||||
: group.platform === 'gemini'
|
: group.platform === 'gemini'
|
||||||
? 'fa-robot'
|
? 'fa-robot'
|
||||||
: 'fa-openai'
|
: group.platform === 'openai'
|
||||||
|
? 'fa-openai'
|
||||||
|
: 'fa-robot'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return options
|
return options
|
||||||
@@ -2044,7 +2070,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
apiClient.get('/admin/openai-accounts', { params }),
|
apiClient.get('/admin/openai-accounts', { params }),
|
||||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||||
apiClient.get('/admin/openai-responses-accounts', { params }),
|
apiClient.get('/admin/openai-responses-accounts', { params }),
|
||||||
apiClient.get('/admin/ccr-accounts', { params })
|
apiClient.get('/admin/ccr-accounts', { params }),
|
||||||
|
apiClient.get('/admin/droid-accounts', { params })
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 只请求指定平台,其他平台设为null占位
|
// 只请求指定平台,其他平台设为null占位
|
||||||
@@ -2057,7 +2084,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'claude-console':
|
case 'claude-console':
|
||||||
@@ -2068,7 +2097,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'bedrock':
|
case 'bedrock':
|
||||||
@@ -2079,7 +2110,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
@@ -2090,7 +2123,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
apiClient.get('/admin/gemini-accounts', { params }),
|
apiClient.get('/admin/gemini-accounts', { params }),
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'openai':
|
case 'openai':
|
||||||
@@ -2101,7 +2136,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
apiClient.get('/admin/openai-accounts', { params }),
|
apiClient.get('/admin/openai-accounts', { params }),
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'azure_openai':
|
case 'azure_openai':
|
||||||
@@ -2112,7 +2149,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'openai-responses':
|
case 'openai-responses':
|
||||||
@@ -2123,7 +2162,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||||
apiClient.get('/admin/openai-responses-accounts', { params })
|
apiClient.get('/admin/openai-responses-accounts', { params }),
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'ccr':
|
case 'ccr':
|
||||||
@@ -2134,7 +2175,22 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
Promise.resolve({ success: true, data: [] }), // azure 占位
|
Promise.resolve({ success: true, data: [] }), // azure 占位
|
||||||
apiClient.get('/admin/ccr-accounts', { params })
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
apiClient.get('/admin/ccr-accounts', { params }),
|
||||||
|
Promise.resolve({ success: true, data: [] }) // droid 占位
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'droid':
|
||||||
|
requests.push(
|
||||||
|
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // azure 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||||
|
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||||
|
apiClient.get('/admin/droid-accounts', { params })
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -2146,6 +2202,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
Promise.resolve({ success: true, data: [] }),
|
Promise.resolve({ success: true, data: [] }),
|
||||||
Promise.resolve({ success: true, data: [] }),
|
Promise.resolve({ success: true, data: [] }),
|
||||||
Promise.resolve({ success: true, data: [] }),
|
Promise.resolve({ success: true, data: [] }),
|
||||||
|
Promise.resolve({ success: true, data: [] }),
|
||||||
|
Promise.resolve({ success: true, data: [] }),
|
||||||
Promise.resolve({ success: true, data: [] })
|
Promise.resolve({ success: true, data: [] })
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
@@ -2166,7 +2224,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
openaiData,
|
openaiData,
|
||||||
azureOpenaiData,
|
azureOpenaiData,
|
||||||
openaiResponsesData,
|
openaiResponsesData,
|
||||||
ccrData
|
ccrData,
|
||||||
|
droidData
|
||||||
] = await Promise.all(requests)
|
] = await Promise.all(requests)
|
||||||
|
|
||||||
const allAccounts = []
|
const allAccounts = []
|
||||||
@@ -2260,6 +2319,18 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
allAccounts.push(...ccrAccounts)
|
allAccounts.push(...ccrAccounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Droid 账户
|
||||||
|
if (droidData && droidData.success) {
|
||||||
|
const droidAccounts = (droidData.data || []).map((acc) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
platform: 'droid',
|
||||||
|
boundApiKeysCount: acc.boundApiKeysCount ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
allAccounts.push(...droidAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
// 根据分组筛选器过滤账户
|
// 根据分组筛选器过滤账户
|
||||||
let filteredAccounts = allAccounts
|
let filteredAccounts = allAccounts
|
||||||
if (groupFilter.value !== 'all') {
|
if (groupFilter.value !== 'all') {
|
||||||
@@ -2600,6 +2671,8 @@ const resolveAccountDeleteEndpoint = (account) => {
|
|||||||
return `/admin/ccr-accounts/${account.id}`
|
return `/admin/ccr-accounts/${account.id}`
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return `/admin/gemini-accounts/${account.id}`
|
return `/admin/gemini-accounts/${account.id}`
|
||||||
|
case 'droid':
|
||||||
|
return `/admin/droid-accounts/${account.id}`
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -2778,6 +2851,8 @@ const resetAccountStatus = async (account) => {
|
|||||||
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
||||||
} else if (account.platform === 'ccr') {
|
} else if (account.platform === 'ccr') {
|
||||||
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
|
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
|
||||||
|
} else if (account.platform === 'droid') {
|
||||||
|
endpoint = `/admin/droid-accounts/${account.id}/reset-status`
|
||||||
} else {
|
} else {
|
||||||
showToast('不支持的账户类型', 'error')
|
showToast('不支持的账户类型', 'error')
|
||||||
account.isResetting = false
|
account.isResetting = false
|
||||||
@@ -2824,6 +2899,8 @@ const toggleSchedulable = async (account) => {
|
|||||||
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable`
|
||||||
} else if (account.platform === 'ccr') {
|
} else if (account.platform === 'ccr') {
|
||||||
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
||||||
|
} else if (account.platform === 'droid') {
|
||||||
|
endpoint = `/admin/droid-accounts/${account.id}/toggle-schedulable`
|
||||||
} else {
|
} else {
|
||||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -511,6 +511,18 @@
|
|||||||
{{ getBedrockBindingInfo(key) }}
|
{{ getBedrockBindingInfo(key) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Droid 绑定 -->
|
||||||
|
<div v-if="key.droidAccountId" class="flex items-center gap-1 text-xs">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded bg-cyan-100 px-1.5 py-0.5 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot mr-1 text-[10px]" />
|
||||||
|
Droid
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-gray-600 dark:text-gray-400">
|
||||||
|
{{ getDroidBindingInfo(key) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<!-- 共享池 -->
|
<!-- 共享池 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -518,7 +530,8 @@
|
|||||||
!key.claudeConsoleAccountId &&
|
!key.claudeConsoleAccountId &&
|
||||||
!key.geminiAccountId &&
|
!key.geminiAccountId &&
|
||||||
!key.openaiAccountId &&
|
!key.openaiAccountId &&
|
||||||
!key.bedrockAccountId
|
!key.bedrockAccountId &&
|
||||||
|
!key.droidAccountId
|
||||||
"
|
"
|
||||||
class="text-xs text-gray-500 dark:text-gray-400"
|
class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
@@ -594,6 +607,47 @@
|
|||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 时间窗口费用限制(无每日和总费用限制时展示) -->
|
||||||
|
<div
|
||||||
|
v-else-if="
|
||||||
|
key.rateLimitWindow > 0 &&
|
||||||
|
key.rateLimitCost > 0 &&
|
||||||
|
(!key.dailyCostLimit || key.dailyCostLimit === 0) &&
|
||||||
|
(!key.totalCostLimit || key.totalCostLimit === 0)
|
||||||
|
"
|
||||||
|
class="space-y-1.5"
|
||||||
|
>
|
||||||
|
<!-- 费用进度条 -->
|
||||||
|
<LimitProgressBar
|
||||||
|
:current="key.currentWindowCost || 0"
|
||||||
|
label="窗口费用"
|
||||||
|
:limit="key.rateLimitCost"
|
||||||
|
type="window"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
<!-- 重置倒计时 -->
|
||||||
|
<div class="flex items-center justify-between text-[10px]">
|
||||||
|
<div class="flex items-center gap-1 text-sky-600 dark:text-sky-300">
|
||||||
|
<i class="fas fa-clock text-[10px]" />
|
||||||
|
<span class="font-medium">{{ key.rateLimitWindow }}分钟窗口</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
:class="
|
||||||
|
key.windowRemainingSeconds > 0
|
||||||
|
? 'text-sky-700 dark:text-sky-300'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
key.windowRemainingSeconds > 0
|
||||||
|
? formatWindowTime(key.windowRemainingSeconds)
|
||||||
|
: '未激活'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 如果没有任何限制 -->
|
<!-- 如果没有任何限制 -->
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -1141,6 +1195,18 @@
|
|||||||
{{ getBedrockBindingInfo(key) }}
|
{{ getBedrockBindingInfo(key) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Droid 绑定 -->
|
||||||
|
<div v-if="key.droidAccountId" class="flex flex-wrap items-center gap-1 text-xs">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded bg-cyan-100 px-2 py-0.5 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot mr-1" />
|
||||||
|
Droid
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
{{ getDroidBindingInfo(key) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<!-- 无绑定时显示共享池 -->
|
<!-- 无绑定时显示共享池 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -1148,7 +1214,8 @@
|
|||||||
!key.claudeConsoleAccountId &&
|
!key.claudeConsoleAccountId &&
|
||||||
!key.geminiAccountId &&
|
!key.geminiAccountId &&
|
||||||
!key.openaiAccountId &&
|
!key.openaiAccountId &&
|
||||||
!key.bedrockAccountId
|
!key.bedrockAccountId &&
|
||||||
|
!key.droidAccountId
|
||||||
"
|
"
|
||||||
class="text-xs text-gray-500 dark:text-gray-400"
|
class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
@@ -1221,6 +1288,47 @@
|
|||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 时间窗口费用限制(无每日和总费用限制时展示) -->
|
||||||
|
<div
|
||||||
|
v-else-if="
|
||||||
|
key.rateLimitWindow > 0 &&
|
||||||
|
key.rateLimitCost > 0 &&
|
||||||
|
(!key.dailyCostLimit || key.dailyCostLimit === 0) &&
|
||||||
|
(!key.totalCostLimit || key.totalCostLimit === 0)
|
||||||
|
"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<!-- 费用进度条 -->
|
||||||
|
<LimitProgressBar
|
||||||
|
:current="key.currentWindowCost || 0"
|
||||||
|
label="窗口费用"
|
||||||
|
:limit="key.rateLimitCost"
|
||||||
|
type="window"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
<!-- 重置倒计时 -->
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<div class="flex items-center gap-1.5 text-sky-600 dark:text-sky-300">
|
||||||
|
<i class="fas fa-clock text-xs" />
|
||||||
|
<span class="font-medium">{{ key.rateLimitWindow }}分钟窗口</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
:class="
|
||||||
|
key.windowRemainingSeconds > 0
|
||||||
|
? 'text-sky-700 dark:text-sky-300'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
key.windowRemainingSeconds > 0
|
||||||
|
? formatWindowTime(key.windowRemainingSeconds)
|
||||||
|
: '未激活'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 无限制显示 -->
|
<!-- 无限制显示 -->
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -1839,9 +1947,11 @@ const accounts = ref({
|
|||||||
openai: [],
|
openai: [],
|
||||||
openaiResponses: [], // 添加 OpenAI-Responses 账号列表
|
openaiResponses: [], // 添加 OpenAI-Responses 账号列表
|
||||||
bedrock: [],
|
bedrock: [],
|
||||||
|
droid: [],
|
||||||
claudeGroups: [],
|
claudeGroups: [],
|
||||||
geminiGroups: [],
|
geminiGroups: [],
|
||||||
openaiGroups: []
|
openaiGroups: [],
|
||||||
|
droidGroups: []
|
||||||
})
|
})
|
||||||
const editingExpiryKey = ref(null)
|
const editingExpiryKey = ref(null)
|
||||||
const expiryEditModalRef = ref(null)
|
const expiryEditModalRef = ref(null)
|
||||||
@@ -1949,12 +2059,17 @@ const getBindingDisplayStrings = (key) => {
|
|||||||
appendBindingRow('Bedrock', getBedrockBindingInfo(key))
|
appendBindingRow('Bedrock', getBedrockBindingInfo(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key.droidAccountId) {
|
||||||
|
appendBindingRow('Droid', getDroidBindingInfo(key))
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!key.claudeAccountId &&
|
!key.claudeAccountId &&
|
||||||
!key.claudeConsoleAccountId &&
|
!key.claudeConsoleAccountId &&
|
||||||
!key.geminiAccountId &&
|
!key.geminiAccountId &&
|
||||||
!key.openaiAccountId &&
|
!key.openaiAccountId &&
|
||||||
!key.bedrockAccountId
|
!key.bedrockAccountId &&
|
||||||
|
!key.droidAccountId
|
||||||
) {
|
) {
|
||||||
collect('共享池')
|
collect('共享池')
|
||||||
}
|
}
|
||||||
@@ -2114,6 +2229,7 @@ const loadAccounts = async () => {
|
|||||||
openaiData,
|
openaiData,
|
||||||
openaiResponsesData,
|
openaiResponsesData,
|
||||||
bedrockData,
|
bedrockData,
|
||||||
|
droidData,
|
||||||
groupsData
|
groupsData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
apiClient.get('/admin/claude-accounts'),
|
||||||
@@ -2122,6 +2238,7 @@ const loadAccounts = async () => {
|
|||||||
apiClient.get('/admin/openai-accounts'),
|
apiClient.get('/admin/openai-accounts'),
|
||||||
apiClient.get('/admin/openai-responses-accounts'), // 加载 OpenAI-Responses 账号
|
apiClient.get('/admin/openai-responses-accounts'), // 加载 OpenAI-Responses 账号
|
||||||
apiClient.get('/admin/bedrock-accounts'),
|
apiClient.get('/admin/bedrock-accounts'),
|
||||||
|
apiClient.get('/admin/droid-accounts'),
|
||||||
apiClient.get('/admin/account-groups')
|
apiClient.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -2178,12 +2295,21 @@ const loadAccounts = async () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (droidData.success) {
|
||||||
|
accounts.value.droid = (droidData.data || []).map((account) => ({
|
||||||
|
...account,
|
||||||
|
platform: 'droid',
|
||||||
|
isDedicated: account.accountType === 'dedicated'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
if (groupsData.success) {
|
if (groupsData.success) {
|
||||||
// 处理分组数据
|
// 处理分组数据
|
||||||
const allGroups = groupsData.data || []
|
const allGroups = groupsData.data || []
|
||||||
accounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
accounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
||||||
accounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
accounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
||||||
accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
|
||||||
|
accounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('加载账户列表失败:', error)
|
// console.error('加载账户列表失败:', error)
|
||||||
@@ -2299,6 +2425,11 @@ const getBoundAccountName = (accountId) => {
|
|||||||
return `分组-${openaiGroup.name}`
|
return `分组-${openaiGroup.name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const droidGroup = accounts.value.droidGroups.find((g) => g.id === groupId)
|
||||||
|
if (droidGroup) {
|
||||||
|
return `分组-${droidGroup.name}`
|
||||||
|
}
|
||||||
|
|
||||||
// 如果找不到分组,返回分组ID的前8位
|
// 如果找不到分组,返回分组ID的前8位
|
||||||
return `分组-${groupId.substring(0, 8)}`
|
return `分组-${groupId.substring(0, 8)}`
|
||||||
}
|
}
|
||||||
@@ -2346,6 +2477,11 @@ const getBoundAccountName = (accountId) => {
|
|||||||
return `${bedrockAccount.name}`
|
return `${bedrockAccount.name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const droidAccount = accounts.value.droid.find((acc) => acc.id === accountId)
|
||||||
|
if (droidAccount) {
|
||||||
|
return `${droidAccount.name}`
|
||||||
|
}
|
||||||
|
|
||||||
// 如果找不到,返回账户ID的前8位
|
// 如果找不到,返回账户ID的前8位
|
||||||
return `${accountId.substring(0, 8)}`
|
return `${accountId.substring(0, 8)}`
|
||||||
}
|
}
|
||||||
@@ -2448,6 +2584,24 @@ const getBedrockBindingInfo = (key) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDroidBindingInfo = (key) => {
|
||||||
|
if (key.droidAccountId) {
|
||||||
|
const info = getBoundAccountName(key.droidAccountId)
|
||||||
|
if (key.droidAccountId.startsWith('group:')) {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
const account = accounts.value.droid.find((acc) => acc.id === key.droidAccountId)
|
||||||
|
if (!account) {
|
||||||
|
return `⚠️ ${info} (账户不存在)`
|
||||||
|
}
|
||||||
|
if (account.accountType === 'dedicated') {
|
||||||
|
return `🔒 专属-${info}`
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
// 检查API Key是否过期
|
// 检查API Key是否过期
|
||||||
const isApiKeyExpired = (expiresAt) => {
|
const isApiKeyExpired = (expiresAt) => {
|
||||||
if (!expiresAt) return false
|
if (!expiresAt) return false
|
||||||
@@ -3432,6 +3586,23 @@ const formatDate = (dateString) => {
|
|||||||
.replace(/\//g, '-')
|
.replace(/\//g, '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化时间窗口倒计时
|
||||||
|
const formatWindowTime = (seconds) => {
|
||||||
|
if (seconds === null || seconds === undefined) return '--:--'
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h${minutes}m`
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m${secs}s`
|
||||||
|
} else {
|
||||||
|
return `${secs}s`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取每日费用进度 - 已移到 LimitProgressBar 组件中
|
// 获取每日费用进度 - 已移到 LimitProgressBar 组件中
|
||||||
// const getDailyCostProgress = (key) => {
|
// const getDailyCostProgress = (key) => {
|
||||||
// if (!key.dailyCostLimit || key.dailyCostLimit === 0) return 0
|
// if (!key.dailyCostLimit || key.dailyCostLimit === 0) return 0
|
||||||
@@ -3555,6 +3726,8 @@ const exportToExcel = () => {
|
|||||||
? '仅Gemini'
|
? '仅Gemini'
|
||||||
: key.permissions === 'openai'
|
: key.permissions === 'openai'
|
||||||
? '仅OpenAI'
|
? '仅OpenAI'
|
||||||
|
: key.permissions === 'droid'
|
||||||
|
? '仅Droid'
|
||||||
: key.permissions || '',
|
: key.permissions || '',
|
||||||
|
|
||||||
// 限制配置
|
// 限制配置
|
||||||
@@ -3587,6 +3760,7 @@ const exportToExcel = () => {
|
|||||||
OpenAI专属账户: key.openaiAccountId || '',
|
OpenAI专属账户: key.openaiAccountId || '',
|
||||||
'Azure OpenAI专属账户': key.azureOpenaiAccountId || '',
|
'Azure OpenAI专属账户': key.azureOpenaiAccountId || '',
|
||||||
Bedrock专属账户: key.bedrockAccountId || '',
|
Bedrock专属账户: key.bedrockAccountId || '',
|
||||||
|
Droid专属账户: key.droidAccountId || '',
|
||||||
|
|
||||||
// 模型和客户端限制
|
// 模型和客户端限制
|
||||||
启用模型限制: key.enableModelRestriction ? '是' : '否',
|
启用模型限制: key.enableModelRestriction ? '是' : '否',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user