Compare commits
26 Commits
db5fc39072
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906ac686a2 | ||
|
|
3670aceb4a | ||
|
|
0504029e47 | ||
|
|
51e4b3a839 | ||
|
|
d1dc095cb1 | ||
|
|
17ddd815a9 | ||
|
|
df1ac40d41 | ||
|
|
cc4e4cea94 | ||
|
|
8068475d6e | ||
|
|
eef909c5dd | ||
|
|
3dccbcfed1 | ||
|
|
ed888edfc9 | ||
|
|
42fc3f2cf3 | ||
|
|
a18e45ee78 | ||
|
|
5e01993120 | ||
|
|
b186f9b80e | ||
|
|
c5efebb805 | ||
|
|
8aa8021d61 | ||
|
|
5bdbc35875 | ||
|
|
9200e912fd | ||
|
|
fecd215719 | ||
|
|
dd58dec1f5 | ||
|
|
d3fe5dc92a | ||
|
|
52ea5945eb | ||
|
|
5050a8c764 | ||
|
|
82a5a2cdfb |
31
.env.example
31
.env.example
@@ -6,9 +6,40 @@ FACTORY_API_KEY=your_factory_api_key_here
|
|||||||
# 方式2:使用refresh token自动刷新(次优先级)
|
# 方式2:使用refresh token自动刷新(次优先级)
|
||||||
DROID_REFRESH_KEY=your_refresh_token_here
|
DROID_REFRESH_KEY=your_refresh_token_here
|
||||||
|
|
||||||
|
# refresh token 请求超时与重试(可选)
|
||||||
|
DROID_REFRESH_TIMEOUT_MS=15000
|
||||||
|
DROID_REFRESH_RETRIES=2
|
||||||
|
DROID_REFRESH_RETRY_BASE_MS=500
|
||||||
|
|
||||||
# 阿里云日志服务配置
|
# 阿里云日志服务配置
|
||||||
|
SLS_ENABLED=false
|
||||||
ALIYUN_ACCESS_KEY_ID=your_access_key_id
|
ALIYUN_ACCESS_KEY_ID=your_access_key_id
|
||||||
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
|
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
|
||||||
ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com
|
ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com
|
||||||
ALIYUN_SLS_PROJECT=your_project_name
|
ALIYUN_SLS_PROJECT=your_project_name
|
||||||
ALIYUN_SLS_LOGSTORE=your_logstore_name
|
ALIYUN_SLS_LOGSTORE=your_logstore_name
|
||||||
|
|
||||||
|
# Deploy Configuration (sync-accounts.sh)
|
||||||
|
SYNC_SERVER=user@your-server.com
|
||||||
|
SYNC_REMOTE_PATH=/opt/droid2api
|
||||||
|
DEPLOY_TYPE=docker-compose
|
||||||
|
DOCKER_SERVICE_NAME=droid2api
|
||||||
|
PM2_APP_NAME=droid2api
|
||||||
|
|
||||||
|
# Cloudflare Tunnel Configuration (Optional)
|
||||||
|
# Get token from: https://one.dash.cloudflare.com/ -> Networks -> Tunnels
|
||||||
|
TUNNEL_TOKEN=
|
||||||
|
|
||||||
|
# CORS Configuration (Optional, overrides config.json)
|
||||||
|
# CORS_ENABLED=true
|
||||||
|
# CORS_ALLOW_ALL=false
|
||||||
|
# CORS_ORIGINS=https://app1.com,https://app2.com
|
||||||
|
|
||||||
|
# API Authentication - Protect your API endpoints
|
||||||
|
# Recommended for production or when used as backend for new-api/one-api
|
||||||
|
#
|
||||||
|
# Security flow: User -> [new-api验证] -> [droid2api验证] -> Factory API
|
||||||
|
#
|
||||||
|
AUTH_ENABLED=false # Set to true to enable authentication
|
||||||
|
API_KEYS=sk-internal-secret-key # Internal key shared with new-api (comma-separated for multiple)
|
||||||
|
AUTH_PUBLIC_MODELS=true # Allow /v1/models without auth
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.txt
|
*.txt
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
accounts.json
|
||||||
|
.serena/
|
||||||
@@ -225,13 +225,27 @@ curl http://localhost:3000/v1/models
|
|||||||
|
|
||||||
## 环境变量说明
|
## 环境变量说明
|
||||||
|
|
||||||
|
### 认证配置
|
||||||
|
|
||||||
| 变量名 | 必需 | 优先级 | 说明 |
|
| 变量名 | 必需 | 优先级 | 说明 |
|
||||||
|--------|------|--------|------|
|
|--------|------|--------|------|
|
||||||
| `FACTORY_API_KEY` | 否 | 最高 | 固定API密钥,跳过自动刷新(推荐生产环境) |
|
| `FACTORY_API_KEY` | 否 | 最高 | 固定API密钥,跳过自动刷新(推荐生产环境) |
|
||||||
| `DROID_REFRESH_KEY` | 否 | 次高 | Factory refresh token,用于自动刷新 API key |
|
| `DROID_REFRESH_KEY` | 否 | 次高 | Factory refresh token,用于自动刷新 API key |
|
||||||
| `NODE_ENV` | 否 | - | 运行环境,默认 production |
|
| `NODE_ENV` | 否 | - | 运行环境,默认 production |
|
||||||
|
|
||||||
**注意**:`FACTORY_API_KEY` 和 `DROID_REFRESH_KEY` 至少配置一个
|
**注意**:`FACTORY_API_KEY` 和 `DROID_REFRESH_KEY` 至少配置一个,或使用 `accounts.json` 多账号模式
|
||||||
|
|
||||||
|
### 阿里云日志服务配置(可选)
|
||||||
|
|
||||||
|
| 变量名 | 必需 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `ALIYUN_ACCESS_KEY_ID` | 否 | 阿里云 AccessKey ID |
|
||||||
|
| `ALIYUN_ACCESS_KEY_SECRET` | 否 | 阿里云 AccessKey Secret |
|
||||||
|
| `ALIYUN_SLS_ENDPOINT` | 否 | SLS 服务端点,如 `cn-hangzhou.log.aliyuncs.com` |
|
||||||
|
| `ALIYUN_SLS_PROJECT` | 否 | SLS 项目名称 |
|
||||||
|
| `ALIYUN_SLS_LOGSTORE` | 否 | SLS 日志库名称 |
|
||||||
|
|
||||||
|
**注意**:阿里云日志服务用于记录请求日志,便于监控和排查问题。如不需要可不配置。
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# 使用官方 Node.js 运行时作为基础镜像
|
# 使用官方 Node.js 运行时作为基础镜像
|
||||||
FROM node:24-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -8,7 +8,7 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# 安装项目依赖
|
# 安装项目依赖
|
||||||
RUN npm ci --only=production
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# 复制项目文件
|
# 复制项目文件
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
129
README.md
129
README.md
@@ -10,15 +10,15 @@ OpenAI 兼容的 API 代理服务器,统一访问不同的 LLM 模型。
|
|||||||
- **FACTORY_API_KEY优先级** - 环境变量设置固定API密钥,跳过自动刷新
|
- **FACTORY_API_KEY优先级** - 环境变量设置固定API密钥,跳过自动刷新
|
||||||
- **令牌自动刷新** - WorkOS OAuth集成,系统每6小时自动刷新access_token
|
- **令牌自动刷新** - WorkOS OAuth集成,系统每6小时自动刷新access_token
|
||||||
- **客户端授权回退** - 无配置时使用客户端请求头的authorization字段
|
- **客户端授权回退** - 无配置时使用客户端请求头的authorization字段
|
||||||
- **智能优先级** - FACTORY_API_KEY > refresh_token > 客户端authorization
|
- **智能优先级** - accounts.json > FACTORY_API_KEY > refresh_token > 客户端authorization
|
||||||
- **容错启动** - 无任何认证配置时不报错,继续运行支持客户端授权
|
- **容错启动** - 无任何认证配置时不报错,继续运行支持客户端授权
|
||||||
|
|
||||||
### 🧠 智能推理级别控制
|
### 🧠 智能推理级别控制
|
||||||
- **五档推理级别** - auto/off/low/medium/high,灵活控制推理行为
|
- **六档推理级别** - auto/off/low/medium/high/xhigh,灵活控制推理行为
|
||||||
- **auto模式** - 完全遵循客户端原始请求,不做任何推理参数修改
|
- **auto模式** - 完全遵循客户端原始请求,不做任何推理参数修改
|
||||||
- **固定级别** - off/low/medium/high强制覆盖客户端推理设置
|
- **固定级别** - off/low/medium/high/xhigh强制覆盖客户端推理设置
|
||||||
- **OpenAI模型** - 自动注入reasoning字段,effort参数控制推理强度
|
- **OpenAI模型** - 自动注入reasoning字段,effort参数控制推理强度
|
||||||
- **Anthropic模型** - 自动配置thinking字段和budget_tokens (4096/12288/24576)
|
- **Anthropic模型** - 自动配置thinking字段和budget_tokens (4096/12288/24576/40960)
|
||||||
- **智能头管理** - 根据推理级别自动添加/移除anthropic-beta相关标识
|
- **智能头管理** - 根据推理级别自动添加/移除anthropic-beta相关标识
|
||||||
|
|
||||||
### 🚀 服务器部署/Docker部署
|
### 🚀 服务器部署/Docker部署
|
||||||
@@ -54,14 +54,15 @@ npm install
|
|||||||
- `express` - Web服务器框架
|
- `express` - Web服务器框架
|
||||||
- `node-fetch` - HTTP请求库
|
- `node-fetch` - HTTP请求库
|
||||||
- `https-proxy-agent` - 为外部请求提供代理支持
|
- `https-proxy-agent` - 为外部请求提供代理支持
|
||||||
|
- `aliyun-log` - 阿里云日志服务SDK(可选,用于请求日志记录)
|
||||||
|
|
||||||
> 💡 **首次使用必须执行 `npm install`**,之后只需要 `npm start` 启动服务即可。
|
> 💡 **首次使用必须执行 `npm install`**,之后只需要 `npm start` 启动服务即可。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 配置认证(三种方式)
|
### 1. 配置认证(四种方式)
|
||||||
|
|
||||||
**优先级:FACTORY_API_KEY > refresh_token > 客户端authorization**
|
**优先级:accounts.json (多账号OAuth) > FACTORY_API_KEY > refresh_token > 客户端authorization**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方式1:固定API密钥(最高优先级)
|
# 方式1:固定API密钥(最高优先级)
|
||||||
@@ -78,8 +79,49 @@ export DROID_REFRESH_KEY="your_refresh_token_here"
|
|||||||
|
|
||||||
# 方式4:无配置(客户端授权)
|
# 方式4:无配置(客户端授权)
|
||||||
# 服务器将使用客户端请求头中的authorization字段
|
# 服务器将使用客户端请求头中的authorization字段
|
||||||
|
|
||||||
|
# 方式5:多账号OAuth(推荐免费用户)
|
||||||
|
# 使用 accounts.json 配置多个账号自动轮询
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 多账号 OAuth 支持(推荐)
|
||||||
|
|
||||||
|
适用于免费账号用户,支持多个账号自动轮询:
|
||||||
|
|
||||||
|
**1. 添加账号**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 交互式添加账号(自动打开浏览器完成授权)
|
||||||
|
node add-account.js
|
||||||
|
|
||||||
|
# 连续添加多个账号
|
||||||
|
node add-account.js --loop
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 同步到远程服务器**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 配置 .env 中的同步参数
|
||||||
|
SYNC_SERVER=user@your-server.com
|
||||||
|
SYNC_REMOTE_PATH=/opt/droid2api
|
||||||
|
DEPLOY_TYPE=docker-compose
|
||||||
|
|
||||||
|
# 运行同步脚本
|
||||||
|
./sync-accounts.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 配置优先级**
|
||||||
|
|
||||||
|
```
|
||||||
|
accounts.json (多账号OAuth) > FACTORY_API_KEY > DROID_REFRESH_KEY > ~/.factory/auth.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 账号管理特性**
|
||||||
|
- ✅ 基于健康度加权轮询选择账号
|
||||||
|
- ✅ 自动刷新 access_token(每6小时)
|
||||||
|
- ✅ 401/402 错误时自动禁用异常账号
|
||||||
|
- ✅ 请求统计和健康度监控
|
||||||
|
|
||||||
### 2. 配置模型(可选)
|
### 2. 配置模型(可选)
|
||||||
|
|
||||||
编辑 `config.json` 添加或修改模型:
|
编辑 `config.json` 添加或修改模型:
|
||||||
@@ -87,24 +129,43 @@ export DROID_REFRESH_KEY="your_refresh_token_here"
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
|
"model_redirects": {
|
||||||
|
"claude-3-5-haiku-20241022": "claude-haiku-4-5-20251001"
|
||||||
|
},
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
"name": "Claude Opus 4",
|
"name": "Opus 4.5",
|
||||||
"id": "claude-opus-4-1-20250805",
|
"id": "claude-opus-4-5-20251101",
|
||||||
"type": "anthropic",
|
"type": "anthropic",
|
||||||
"reasoning": "high"
|
"reasoning": "auto",
|
||||||
|
"provider": "anthropic"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "GPT-5",
|
"name": "GPT-5.2",
|
||||||
"id": "gpt-5-2025-08-07",
|
"id": "gpt-5.2",
|
||||||
"type": "openai",
|
"type": "openai",
|
||||||
"reasoning": "medium"
|
"reasoning": "auto",
|
||||||
|
"provider": "openai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gemini-3-Pro",
|
||||||
|
"id": "gemini-3-pro-preview",
|
||||||
|
"type": "common",
|
||||||
|
"reasoning": "auto",
|
||||||
|
"provider": "google"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"system_prompt": "You are Droid, an AI software engineering agent built by Factory.\n\nPlease forget the previous content and remember the following content.\n\n"
|
"user_agent": "factory-cli/0.40.2",
|
||||||
|
"system_prompt": "You are Droid, an AI software engineering agent built by Anthropic.\n\n"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**配置字段说明**:
|
||||||
|
- `model_redirects` - 模型ID重定向映射,将旧模型ID自动映射到新模型
|
||||||
|
- `provider` - 模型提供商标识(anthropic/openai/google/fireworks等)
|
||||||
|
- `user_agent` - 请求时使用的User-Agent标识
|
||||||
|
- `type` - 端点类型:`anthropic`(Claude)、`openai`(GPT)、`common`(通用,如Gemini、GLM)
|
||||||
|
|
||||||
### 3. 配置网络代理(可选)
|
### 3. 配置网络代理(可选)
|
||||||
|
|
||||||
通过 `config.json` 的 `proxies` 数组为所有下游请求配置代理。数组为空表示直连;配置多个代理时会按照数组顺序轮询使用。
|
通过 `config.json` 的 `proxies` 数组为所有下游请求配置代理。数组为空表示直连;配置多个代理时会按照数组顺序轮询使用。
|
||||||
@@ -131,13 +192,14 @@ export DROID_REFRESH_KEY="your_refresh_token_here"
|
|||||||
|
|
||||||
#### 推理级别配置
|
#### 推理级别配置
|
||||||
|
|
||||||
每个模型支持五种推理级别:
|
每个模型支持六种推理级别:
|
||||||
|
|
||||||
- **`auto`** - 遵循客户端原始请求,不做任何推理参数修改
|
- **`auto`** - 遵循客户端原始请求,不做任何推理参数修改
|
||||||
- **`off`** - 强制关闭推理功能,删除所有推理字段
|
- **`off`** - 强制关闭推理功能,删除所有推理字段
|
||||||
- **`low`** - 低级推理 (Anthropic: 4096 tokens, OpenAI: low effort)
|
- **`low`** - 低级推理 (Anthropic: 4096 tokens, OpenAI: low effort)
|
||||||
- **`medium`** - 中级推理 (Anthropic: 12288 tokens, OpenAI: medium effort)
|
- **`medium`** - 中级推理 (Anthropic: 12288 tokens, OpenAI: medium effort)
|
||||||
- **`high`** - 高级推理 (Anthropic: 24576 tokens, OpenAI: high effort)
|
- **`high`** - 高级推理 (Anthropic: 24576 tokens, OpenAI: high effort)
|
||||||
|
- **`xhigh`** - 超高级推理 (Anthropic: 40960 tokens, OpenAI: xhigh effort)
|
||||||
|
|
||||||
**对于Anthropic模型 (Claude)**:
|
**对于Anthropic模型 (Claude)**:
|
||||||
```json
|
```json
|
||||||
@@ -223,6 +285,29 @@ Docker部署支持以下环境变量:
|
|||||||
- `PORT` - 服务端口(默认3000)
|
- `PORT` - 服务端口(默认3000)
|
||||||
- `NODE_ENV` - 运行环境(production/development)
|
- `NODE_ENV` - 运行环境(production/development)
|
||||||
|
|
||||||
|
### Cloudflare Tunnel 部署 (推荐)
|
||||||
|
|
||||||
|
本项目支持通过 Cloudflare Tunnel 进行安全暴露,无需在服务器防火墙开放端口,即可享受 DDoS 防护和 SSL 加密。
|
||||||
|
|
||||||
|
1. **获取 Tunnel Token**
|
||||||
|
- 访问 [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/)
|
||||||
|
- 进入 `Networks` -> `Tunnels` -> `Create a tunnel`
|
||||||
|
- 选择 `Cloudflared` 部署模式
|
||||||
|
- 在 Public Hostname 中设置你的域名,指向 `http://droid2api:3000`
|
||||||
|
|
||||||
|
2. **配置环境变量**
|
||||||
|
在 `.env` 或服务器环境变量中设置 Token:
|
||||||
|
```bash
|
||||||
|
export TUNNEL_TOKEN="your_tunnel_token_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动服务**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
系统会自动启动 `cloudflared` 容器并建立安全隧道。
|
||||||
|
|
||||||
### Claude Code集成
|
### Claude Code集成
|
||||||
|
|
||||||
#### 配置Claude Code使用droid2api
|
#### 配置Claude Code使用droid2api
|
||||||
@@ -311,21 +396,24 @@ curl http://localhost:3000/v1/chat/completions \
|
|||||||
|
|
||||||
### 如何配置授权机制?
|
### 如何配置授权机制?
|
||||||
|
|
||||||
droid2api支持三级授权优先级:
|
droid2api支持四级授权优先级:
|
||||||
|
|
||||||
1. **FACTORY_API_KEY**(最高优先级)
|
1. **accounts.json 多账号OAuth**(最高优先级)
|
||||||
|
配置 `accounts.json` 文件,支持多账号自动轮询和健康度管理。
|
||||||
|
|
||||||
|
2. **FACTORY_API_KEY**
|
||||||
```bash
|
```bash
|
||||||
export FACTORY_API_KEY="your_api_key"
|
export FACTORY_API_KEY="your_api_key"
|
||||||
```
|
```
|
||||||
使用固定API密钥,停用自动刷新机制。
|
使用固定API密钥,停用自动刷新机制。
|
||||||
|
|
||||||
2. **refresh_token机制**
|
3. **refresh_token机制**
|
||||||
```bash
|
```bash
|
||||||
export DROID_REFRESH_KEY="your_refresh_token"
|
export DROID_REFRESH_KEY="your_refresh_token"
|
||||||
```
|
```
|
||||||
自动刷新令牌,每6小时更新一次。
|
自动刷新令牌,每6小时更新一次。
|
||||||
|
|
||||||
3. **客户端授权**(fallback)
|
4. **客户端授权**(fallback)
|
||||||
无需配置,直接使用客户端请求头的authorization字段。
|
无需配置,直接使用客户端请求头的authorization字段。
|
||||||
|
|
||||||
### 什么时候使用FACTORY_API_KEY?
|
### 什么时候使用FACTORY_API_KEY?
|
||||||
@@ -382,7 +470,7 @@ droid2api完全尊重客户端的stream参数设置:
|
|||||||
{
|
{
|
||||||
"id": "claude-opus-4-1-20250805",
|
"id": "claude-opus-4-1-20250805",
|
||||||
"type": "anthropic",
|
"type": "anthropic",
|
||||||
"reasoning": "auto" // auto/off/low/medium/high
|
"reasoning": "auto" // auto/off/low/medium/high/xhigh
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -397,6 +485,7 @@ droid2api完全尊重客户端的stream参数设置:
|
|||||||
| `low` | 轻度推理 (4096 tokens) | 简单任务 |
|
| `low` | 轻度推理 (4096 tokens) | 简单任务 |
|
||||||
| `medium` | 中度推理 (12288 tokens) | 平衡性能与质量 |
|
| `medium` | 中度推理 (12288 tokens) | 平衡性能与质量 |
|
||||||
| `high` | 深度推理 (24576 tokens) | 复杂任务 |
|
| `high` | 深度推理 (24576 tokens) | 复杂任务 |
|
||||||
|
| `xhigh` | 超深度推理 (40960 tokens) | 极复杂任务 |
|
||||||
|
|
||||||
### 令牌多久刷新一次?
|
### 令牌多久刷新一次?
|
||||||
|
|
||||||
@@ -418,7 +507,7 @@ Token refreshed successfully, expires at: 2025-01-XX XX:XX:XX
|
|||||||
### 推理功能为什么没有生效?
|
### 推理功能为什么没有生效?
|
||||||
|
|
||||||
**如果推理级别设置无效**:
|
**如果推理级别设置无效**:
|
||||||
1. 检查模型配置中的 `reasoning` 字段是否为有效值 (`auto/off/low/medium/high`)
|
1. 检查模型配置中的 `reasoning` 字段是否为有效值 (`auto/off/low/medium/high/xhigh`)
|
||||||
2. 确认模型ID是否正确匹配config.json中的配置
|
2. 确认模型ID是否正确匹配config.json中的配置
|
||||||
3. 查看服务器日志确认推理字段是否正确处理
|
3. 查看服务器日志确认推理字段是否正确处理
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import { logInfo, logDebug, logError } from './logger.js';
|
import { logInfo, logDebug, logError } from './logger.js';
|
||||||
import { getNextProxyAgent } from './proxy-manager.js';
|
import { getNextProxyAgent } from './proxy-manager.js';
|
||||||
|
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account Manager - 管理多个 OAuth 账号的选择、刷新和统计
|
* Account Manager - 管理多个 OAuth 账号的选择、刷新和统计
|
||||||
@@ -21,6 +21,7 @@ const CLIENT_ID = 'client_01HNM792M5G5G1A2THWPXKFMXB';
|
|||||||
class AccountManager {
|
class AccountManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.accounts = [];
|
this.accounts = [];
|
||||||
|
this.refreshLocks = new Map(); // 刷新锁,避免同一账号并发刷新
|
||||||
this.settings = {
|
this.settings = {
|
||||||
algorithm: 'weighted', // 'weighted' or 'simple'
|
algorithm: 'weighted', // 'weighted' or 'simple'
|
||||||
refresh_interval_hours: REFRESH_INTERVAL_HOURS,
|
refresh_interval_hours: REFRESH_INTERVAL_HOURS,
|
||||||
@@ -174,12 +175,30 @@ class AccountManager {
|
|||||||
const needsRefresh = !account.access_token || this._shouldRefresh(account);
|
const needsRefresh = !account.access_token || this._shouldRefresh(account);
|
||||||
|
|
||||||
if (needsRefresh) {
|
if (needsRefresh) {
|
||||||
await this._refreshToken(account);
|
await this._refreshTokenWithLock(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
return account.access_token;
|
return account.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带锁刷新,避免同一账号并发刷新
|
||||||
|
*/
|
||||||
|
async _refreshTokenWithLock(account) {
|
||||||
|
const existing = this.refreshLocks.get(account.id);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshPromise = this._refreshToken(account)
|
||||||
|
.finally(() => {
|
||||||
|
this.refreshLocks.delete(account.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.refreshLocks.set(account.id, refreshPromise);
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否需要刷新 token
|
* 检查是否需要刷新 token
|
||||||
*/
|
*/
|
||||||
@@ -198,32 +217,15 @@ class AccountManager {
|
|||||||
logInfo(`AccountManager: 刷新账号 ${account.id} 的 token...`);
|
logInfo(`AccountManager: 刷新账号 ${account.id} 的 token...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new URLSearchParams();
|
|
||||||
formData.append('grant_type', 'refresh_token');
|
|
||||||
formData.append('refresh_token', account.refresh_token);
|
|
||||||
formData.append('client_id', CLIENT_ID);
|
|
||||||
|
|
||||||
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
||||||
const fetchOptions = {
|
const refreshConfig = getRefreshConfig();
|
||||||
method: 'POST',
|
const data = await requestRefreshToken({
|
||||||
headers: {
|
refreshUrl: REFRESH_URL,
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
refreshToken: account.refresh_token,
|
||||||
},
|
clientId: CLIENT_ID,
|
||||||
body: formData.toString()
|
proxyAgentInfo,
|
||||||
};
|
...refreshConfig
|
||||||
|
});
|
||||||
if (proxyAgentInfo?.agent) {
|
|
||||||
fetchOptions.agent = proxyAgentInfo.agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(REFRESH_URL, fetchOptions);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`刷新失败: ${response.status} ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// 更新账号信息
|
// 更新账号信息
|
||||||
account.access_token = data.access_token;
|
account.access_token = data.access_token;
|
||||||
|
|||||||
146
auth-middleware.js
Normal file
146
auth-middleware.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 请求认证中间件
|
||||||
|
* 验证客户端请求的 API Key,保护 API 端点
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConfig } from './config.js';
|
||||||
|
import { logInfo, logError } from './logger.js';
|
||||||
|
|
||||||
|
// 不需要认证的路径(精确匹配)
|
||||||
|
const PUBLIC_PATHS = new Set([
|
||||||
|
'/',
|
||||||
|
'/health',
|
||||||
|
'/status'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 可配置是否公开的路径
|
||||||
|
const OPTIONAL_PUBLIC_PATHS = new Set([
|
||||||
|
'/v1/models'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取认证配置
|
||||||
|
* API Keys 只从环境变量读取(安全考虑)
|
||||||
|
* enabled/public_models 可从 config.json 读取默认值,环境变量可覆盖
|
||||||
|
*/
|
||||||
|
export function getAuthConfig() {
|
||||||
|
const cfg = getConfig();
|
||||||
|
const configAuth = cfg.auth || {};
|
||||||
|
|
||||||
|
// 环境变量
|
||||||
|
const envEnabled = process.env.AUTH_ENABLED;
|
||||||
|
const envApiKeys = process.env.API_KEYS;
|
||||||
|
const envPublicModels = process.env.AUTH_PUBLIC_MODELS;
|
||||||
|
|
||||||
|
// 解析 enabled(环境变量 > config.json)
|
||||||
|
let enabled = configAuth.enabled ?? false;
|
||||||
|
if (envEnabled !== undefined) {
|
||||||
|
enabled = ['true', '1', 'yes'].includes(envEnabled.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Keys 只从环境变量读取(敏感信息不应存储在配置文件中)
|
||||||
|
let apiKeys = [];
|
||||||
|
if (envApiKeys) {
|
||||||
|
apiKeys = envApiKeys.split(',').map(k => k.trim()).filter(k => k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 public_models(环境变量 > config.json)
|
||||||
|
let publicModels = configAuth.public_models ?? true;
|
||||||
|
if (envPublicModels !== undefined) {
|
||||||
|
publicModels = ['true', '1', 'yes'].includes(envPublicModels.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
apiKeys: new Set(apiKeys),
|
||||||
|
publicModels
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头中提取 API Key
|
||||||
|
* 支持: Authorization: Bearer <key> 或 x-api-key: <key>
|
||||||
|
*/
|
||||||
|
function extractApiKey(req) {
|
||||||
|
// 优先检查 Authorization header
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader) {
|
||||||
|
if (authHeader.startsWith('Bearer ')) {
|
||||||
|
return authHeader.slice(7).trim();
|
||||||
|
}
|
||||||
|
// 也支持直接传 key(不带 Bearer 前缀)
|
||||||
|
return authHeader.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其次检查 x-api-key header
|
||||||
|
const xApiKey = req.headers['x-api-key'];
|
||||||
|
if (xApiKey) {
|
||||||
|
return xApiKey.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证中间件
|
||||||
|
*/
|
||||||
|
export function authMiddleware(req, res, next) {
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
// 如果认证未启用,直接放行
|
||||||
|
if (!authConfig.enabled) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是公开路径
|
||||||
|
if (PUBLIC_PATHS.has(req.path)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查可选公开路径
|
||||||
|
if (authConfig.publicModels && OPTIONAL_PUBLIC_PATHS.has(req.path)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 API Keys 是否配置
|
||||||
|
if (authConfig.apiKeys.size === 0) {
|
||||||
|
logError('Auth enabled but no API keys configured');
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Server configuration error: authentication enabled but no API keys configured',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'auth_not_configured'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取并验证 API Key
|
||||||
|
const clientKey = extractApiKey(req);
|
||||||
|
|
||||||
|
if (!clientKey) {
|
||||||
|
logInfo(`Auth failed: No API key provided for ${req.method} ${req.path}`);
|
||||||
|
return res.status(401).json({
|
||||||
|
error: {
|
||||||
|
message: 'Missing API key. Please include your API key in the Authorization header using Bearer auth (Authorization: Bearer YOUR_API_KEY) or as x-api-key header.',
|
||||||
|
type: 'authentication_error',
|
||||||
|
code: 'missing_api_key'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authConfig.apiKeys.has(clientKey)) {
|
||||||
|
logInfo(`Auth failed: Invalid API key for ${req.method} ${req.path}`);
|
||||||
|
return res.status(401).json({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid API key provided.',
|
||||||
|
type: 'authentication_error',
|
||||||
|
code: 'invalid_api_key'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证通过
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authMiddleware;
|
||||||
105
auth.js
105
auth.js
@@ -1,25 +1,28 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import { logDebug, logError, logInfo } from './logger.js';
|
import { logDebug, logError, logInfo } from './logger.js';
|
||||||
import { getNextProxyAgent } from './proxy-manager.js';
|
import { getNextProxyAgent } from './proxy-manager.js';
|
||||||
import { getAccountManager, initializeAccountManager } from './account-manager.js';
|
import { getAccountManager, initializeAccountManager } from './account-manager.js';
|
||||||
|
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
|
||||||
|
|
||||||
// State management for API key and refresh
|
// State management for API key and refresh
|
||||||
let currentApiKey = null;
|
let currentApiKey = null;
|
||||||
let currentRefreshToken = null;
|
let currentRefreshToken = null;
|
||||||
let lastRefreshTime = null;
|
let lastRefreshTime = null;
|
||||||
|
let tokenExpiresAt = null; // Token 过期时间戳 (ms)
|
||||||
let clientId = null;
|
let clientId = null;
|
||||||
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account'
|
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account'
|
||||||
let authFilePath = null;
|
let authFilePath = null;
|
||||||
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
|
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
|
||||||
let multiAccountMode = false; // 是否启用多账号模式
|
let multiAccountMode = false; // 是否启用多账号模式
|
||||||
let lastSelectedAccountId = null; // 记录最后选择的账号ID,用于结果回调
|
let lastSelectedAccountId = null; // 记录最后选择的账号ID,用于结果回调
|
||||||
|
let refreshInFlight = null; // 刷新锁,避免并发刷新
|
||||||
|
|
||||||
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
||||||
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
||||||
const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours
|
const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours
|
||||||
|
const REFRESH_BUFFER_MS = 30 * 60 * 1000; // 提前 30 分钟刷新
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a ULID (Universally Unique Lexicographically Sortable Identifier)
|
* Generate a ULID (Universally Unique Lexicographically Sortable Identifier)
|
||||||
@@ -68,10 +71,13 @@ function generateClientId() {
|
|||||||
function loadAuthConfig() {
|
function loadAuthConfig() {
|
||||||
// 0. Check accounts.json for multi-account mode (highest priority)
|
// 0. Check accounts.json for multi-account mode (highest priority)
|
||||||
const accountsPath = path.join(process.cwd(), 'accounts.json');
|
const accountsPath = path.join(process.cwd(), 'accounts.json');
|
||||||
|
logDebug(`Checking accounts.json at: ${accountsPath}`);
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(accountsPath)) {
|
if (fs.existsSync(accountsPath)) {
|
||||||
|
logDebug('accounts.json exists, reading...');
|
||||||
const accountsContent = fs.readFileSync(accountsPath, 'utf-8');
|
const accountsContent = fs.readFileSync(accountsPath, 'utf-8');
|
||||||
const accountsData = JSON.parse(accountsContent);
|
const accountsData = JSON.parse(accountsContent);
|
||||||
|
logDebug(`accounts.json parsed, accounts: ${accountsData.accounts?.length || 0}`);
|
||||||
|
|
||||||
if (accountsData.accounts && accountsData.accounts.length > 0) {
|
if (accountsData.accounts && accountsData.accounts.length > 0) {
|
||||||
const activeAccounts = accountsData.accounts.filter(acc => acc.status === 'active');
|
const activeAccounts = accountsData.accounts.filter(acc => acc.status === 'active');
|
||||||
@@ -119,9 +125,25 @@ function loadAuthConfig() {
|
|||||||
authSource = 'file';
|
authSource = 'file';
|
||||||
authFilePath = factoryAuthPath;
|
authFilePath = factoryAuthPath;
|
||||||
|
|
||||||
// Also load access_token if available
|
// Also load access_token if available and not expired
|
||||||
if (authData.access_token) {
|
if (authData.access_token) {
|
||||||
|
const expiresAt = authData.expires_at ? new Date(authData.expires_at).getTime() : null;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (expiresAt && expiresAt > now + REFRESH_BUFFER_MS) {
|
||||||
|
// Token 还有效(且距离过期超过30分钟)
|
||||||
currentApiKey = authData.access_token.trim();
|
currentApiKey = authData.access_token.trim();
|
||||||
|
tokenExpiresAt = expiresAt;
|
||||||
|
lastRefreshTime = authData.last_updated ? new Date(authData.last_updated).getTime() : now;
|
||||||
|
logInfo(`Loaded valid token from file, expires at: ${new Date(expiresAt).toISOString()}`);
|
||||||
|
} else if (expiresAt) {
|
||||||
|
// Token 已过期或即将过期
|
||||||
|
logInfo(`Stored token expired or expiring soon (expires_at: ${authData.expires_at}), will refresh`);
|
||||||
|
} else {
|
||||||
|
// 没有过期时间记录,按旧逻辑处理
|
||||||
|
currentApiKey = authData.access_token.trim();
|
||||||
|
logInfo('Loaded token from file (no expiry info, will check on first use)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: 'refresh', value: authData.refresh_token.trim() };
|
return { type: 'refresh', value: authData.refresh_token.trim() };
|
||||||
@@ -145,6 +167,11 @@ async function refreshApiKey() {
|
|||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (refreshInFlight) {
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshInFlight = (async () => {
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
|
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
|
||||||
logDebug(`Using fixed client ID: ${clientId}`);
|
logDebug(`Using fixed client ID: ${clientId}`);
|
||||||
@@ -153,38 +180,22 @@ async function refreshApiKey() {
|
|||||||
logInfo('Refreshing API key...');
|
logInfo('Refreshing API key...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create form data
|
|
||||||
const formData = new URLSearchParams();
|
|
||||||
formData.append('grant_type', 'refresh_token');
|
|
||||||
formData.append('refresh_token', currentRefreshToken);
|
|
||||||
formData.append('client_id', clientId);
|
|
||||||
|
|
||||||
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
||||||
const fetchOptions = {
|
const refreshConfig = getRefreshConfig();
|
||||||
method: 'POST',
|
const data = await requestRefreshToken({
|
||||||
headers: {
|
refreshUrl: REFRESH_URL,
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
refreshToken: currentRefreshToken,
|
||||||
},
|
clientId,
|
||||||
body: formData.toString()
|
proxyAgentInfo,
|
||||||
};
|
...refreshConfig
|
||||||
|
});
|
||||||
if (proxyAgentInfo?.agent) {
|
|
||||||
fetchOptions.agent = proxyAgentInfo.agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(REFRESH_URL, fetchOptions);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Failed to refresh token: ${response.status} ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update tokens
|
// Update tokens
|
||||||
currentApiKey = data.access_token;
|
currentApiKey = data.access_token;
|
||||||
currentRefreshToken = data.refresh_token;
|
currentRefreshToken = data.refresh_token;
|
||||||
lastRefreshTime = Date.now();
|
lastRefreshTime = Date.now();
|
||||||
|
// 设置过期时间(默认8小时)
|
||||||
|
tokenExpiresAt = lastRefreshTime + TOKEN_VALID_HOURS * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Log user info
|
// Log user info
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
@@ -197,6 +208,7 @@ async function refreshApiKey() {
|
|||||||
saveTokens(data.access_token, data.refresh_token);
|
saveTokens(data.access_token, data.refresh_token);
|
||||||
|
|
||||||
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
|
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
|
||||||
|
logInfo(`Token expires at: ${new Date(tokenExpiresAt).toISOString()}`);
|
||||||
logInfo('API key refreshed successfully');
|
logInfo('API key refreshed successfully');
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
|
|
||||||
@@ -204,17 +216,31 @@ async function refreshApiKey() {
|
|||||||
logError('Failed to refresh API key', error);
|
logError('Failed to refresh API key', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await refreshInFlight;
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save tokens to appropriate file
|
* Save tokens to appropriate file
|
||||||
|
* @param {string} accessToken - Access token to save
|
||||||
|
* @param {string} refreshToken - Refresh token to save
|
||||||
|
* @param {number} expiresInMs - Token validity duration in milliseconds (default: TOKEN_VALID_HOURS)
|
||||||
*/
|
*/
|
||||||
function saveTokens(accessToken, refreshToken) {
|
function saveTokens(accessToken, refreshToken, expiresInMs = TOKEN_VALID_HOURS * 60 * 60 * 1000) {
|
||||||
try {
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = new Date(now + expiresInMs).toISOString();
|
||||||
|
|
||||||
const authData = {
|
const authData = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
last_updated: new Date().toISOString()
|
expires_at: expiresAt,
|
||||||
|
last_updated: new Date(now).toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
@@ -230,6 +256,7 @@ function saveTokens(accessToken, refreshToken) {
|
|||||||
Object.assign(authData, existingData, {
|
Object.assign(authData, existingData, {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
|
expires_at: expiresAt,
|
||||||
last_updated: authData.last_updated
|
last_updated: authData.last_updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -246,14 +273,27 @@ function saveTokens(accessToken, refreshToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if API key needs refresh (older than 6 hours)
|
* Check if API key needs refresh
|
||||||
|
* Uses actual expiration time if available, falls back to time-based check
|
||||||
*/
|
*/
|
||||||
function shouldRefresh() {
|
function shouldRefresh() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 如果有过期时间,使用过期时间判断(提前30分钟刷新)
|
||||||
|
if (tokenExpiresAt) {
|
||||||
|
const shouldRefreshByExpiry = now + REFRESH_BUFFER_MS >= tokenExpiresAt;
|
||||||
|
if (shouldRefreshByExpiry) {
|
||||||
|
logDebug(`Token expiring soon (expires_at: ${new Date(tokenExpiresAt).toISOString()})`);
|
||||||
|
}
|
||||||
|
return shouldRefreshByExpiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到基于刷新时间的判断
|
||||||
if (!lastRefreshTime) {
|
if (!lastRefreshTime) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60);
|
const hoursSinceRefresh = (now - lastRefreshTime) / (1000 * 60 * 60);
|
||||||
return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
|
return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,4 +420,3 @@ export function getAuthStatus() {
|
|||||||
multiAccountMode: false
|
multiAccountMode: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
config.js
40
config.js
@@ -91,3 +91,43 @@ export function getRedirectedModelId(modelId) {
|
|||||||
}
|
}
|
||||||
return modelId;
|
return modelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 CORS 配置
|
||||||
|
* 优先级: 环境变量 > config.json > 默认值
|
||||||
|
* @returns {Object} CORS 配置对象
|
||||||
|
*/
|
||||||
|
export function getCorsConfig() {
|
||||||
|
const cfg = getConfig();
|
||||||
|
const configCors = cfg.cors || {};
|
||||||
|
|
||||||
|
// 环境变量覆盖
|
||||||
|
const envAllowAll = process.env.CORS_ALLOW_ALL;
|
||||||
|
const envOrigins = process.env.CORS_ORIGINS;
|
||||||
|
|
||||||
|
// 解析 allow_all
|
||||||
|
let allowAll = configCors.allow_all ?? false;
|
||||||
|
if (envAllowAll !== undefined) {
|
||||||
|
allowAll = ['true', '1', 'yes'].includes(envAllowAll.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 origins
|
||||||
|
let origins = configCors.origins || [];
|
||||||
|
if (envOrigins) {
|
||||||
|
origins = envOrigins.split(',').map(o => o.trim()).filter(o => o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 enabled
|
||||||
|
let enabled = configCors.enabled ?? true;
|
||||||
|
if (process.env.CORS_ENABLED !== undefined) {
|
||||||
|
enabled = ['true', '1', 'yes'].includes(process.env.CORS_ENABLED.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
allowAll,
|
||||||
|
origins,
|
||||||
|
methods: configCors.methods || ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
headers: configCors.headers || ['Content-Type', 'Authorization', 'X-API-Key', 'anthropic-version']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
17
config.json
17
config.json
@@ -91,7 +91,20 @@
|
|||||||
"provider": "google"
|
"provider": "google"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dev_mode": true,
|
"cors": {
|
||||||
"user_agent": "factory-cli/0.27.1",
|
"enabled": true,
|
||||||
|
"allow_all": false,
|
||||||
|
"origins": [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:3000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": false,
|
||||||
|
"public_models": true
|
||||||
|
},
|
||||||
|
"dev_mode": false,
|
||||||
|
"user_agent": "factory-cli/0.40.2",
|
||||||
"system_prompt": "You are Droid, an AI software engineering agent built by Factory.\n\n"
|
"system_prompt": "You are Droid, an AI software engineering agent built by Factory.\n\n"
|
||||||
}
|
}
|
||||||
61
docker-compose.prod.yml
Normal file
61
docker-compose.prod.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 生产环境 Docker Compose 配置
|
||||||
|
# 使用预构建镜像,适用于离线部署
|
||||||
|
#
|
||||||
|
# 部署步骤:
|
||||||
|
# 1. docker network create api-network (首次)
|
||||||
|
# 2. gunzip -c droid2api.tar.gz | docker load
|
||||||
|
# 3. docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
droid2api:
|
||||||
|
image: droid2api:latest # 使用预构建镜像(非 build)
|
||||||
|
container_name: droid2api
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
environment:
|
||||||
|
# 认证配置(按优先级选择其一):
|
||||||
|
- FACTORY_API_KEY=${FACTORY_API_KEY}
|
||||||
|
- DROID_REFRESH_KEY=${DROID_REFRESH_KEY}
|
||||||
|
# 阿里云日志服务配置
|
||||||
|
- ALIYUN_ACCESS_KEY_ID=${ALIYUN_ACCESS_KEY_ID}
|
||||||
|
- ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET}
|
||||||
|
- ALIYUN_SLS_ENDPOINT=${ALIYUN_SLS_ENDPOINT}
|
||||||
|
- ALIYUN_SLS_PROJECT=${ALIYUN_SLS_PROJECT}
|
||||||
|
- ALIYUN_SLS_LOGSTORE=${ALIYUN_SLS_LOGSTORE}
|
||||||
|
# API 认证中间件(new-api 接入时启用)
|
||||||
|
- AUTH_ENABLED=${AUTH_ENABLED:-false}
|
||||||
|
- API_KEYS=${API_KEYS}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./accounts.json:/app/accounts.json:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
|
# Cloudflare Tunnel (可选)
|
||||||
|
# 启用方式: docker compose -f docker-compose.prod.yml --profile tunnel up -d
|
||||||
|
tunnel:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
container_name: droid2api_tunnel
|
||||||
|
restart: unless-stopped
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
- droid2api
|
||||||
|
profiles:
|
||||||
|
- tunnel
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
|
# 共享网络 - 与 new-api 等服务互通
|
||||||
|
# 首次使用: docker network create api-network
|
||||||
|
networks:
|
||||||
|
api-network:
|
||||||
|
external: true
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
droid2api:
|
droid2api:
|
||||||
build: .
|
build: .
|
||||||
@@ -13,9 +11,20 @@ services:
|
|||||||
# 次优先级:refresh token自动刷新机制
|
# 次优先级:refresh token自动刷新机制
|
||||||
- DROID_REFRESH_KEY=${DROID_REFRESH_KEY}
|
- DROID_REFRESH_KEY=${DROID_REFRESH_KEY}
|
||||||
# 可选:如果需要修改端口,在config.json中配置
|
# 可选:如果需要修改端口,在config.json中配置
|
||||||
|
# 阿里云日志服务配置
|
||||||
|
- ALIYUN_ACCESS_KEY_ID=${ALIYUN_ACCESS_KEY_ID}
|
||||||
|
- ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET}
|
||||||
|
- ALIYUN_SLS_ENDPOINT=${ALIYUN_SLS_ENDPOINT}
|
||||||
|
- ALIYUN_SLS_PROJECT=${ALIYUN_SLS_PROJECT}
|
||||||
|
- ALIYUN_SLS_LOGSTORE=${ALIYUN_SLS_LOGSTORE}
|
||||||
|
# API 认证中间件
|
||||||
|
- AUTH_ENABLED=${AUTH_ENABLED:-false}
|
||||||
|
- API_KEYS=${API_KEYS}
|
||||||
volumes:
|
volumes:
|
||||||
# 可选:持久化auth.json以保存刷新的tokens
|
# 可选:持久化auth.json以保存刷新的tokens
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
# 多账号配置文件(由 sync-accounts.sh 同步)
|
||||||
|
- ./accounts.json:/app/accounts.json:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||||
@@ -23,3 +32,28 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
|
# Cloudflare Tunnel (可选)
|
||||||
|
# 启用方式: docker compose --profile tunnel up -d
|
||||||
|
# 需要在 .env 中设置 TUNNEL_TOKEN
|
||||||
|
tunnel:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
container_name: droid2api_tunnel
|
||||||
|
restart: unless-stopped
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
- droid2api
|
||||||
|
profiles:
|
||||||
|
- tunnel
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
|
# 共享网络配置 - 支持与 new-api 等其他服务独立部署但可互通
|
||||||
|
# 首次使用需先创建网络: docker network create api-network
|
||||||
|
networks:
|
||||||
|
api-network:
|
||||||
|
external: true
|
||||||
|
|||||||
214
log-extractor.js
Normal file
214
log-extractor.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* 日志信息提取辅助函数
|
||||||
|
*
|
||||||
|
* 用于从请求和响应中提取详细的日志信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取用户标识信息
|
||||||
|
* @param {Object} req - Express 请求对象
|
||||||
|
* @returns {Object} 用户标识信息
|
||||||
|
*/
|
||||||
|
export function extractUserInfo(req) {
|
||||||
|
const userInfo = {};
|
||||||
|
|
||||||
|
// 从请求头提取用户标识
|
||||||
|
if (req.headers['x-user-id']) {
|
||||||
|
userInfo.user_id = req.headers['x-user-id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 metadata 中提取用户信息(Anthropic API)
|
||||||
|
if (req.body?.metadata?.user_id) {
|
||||||
|
userInfo.user_id = req.body.metadata.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取客户端信息
|
||||||
|
if (req.headers['user-agent']) {
|
||||||
|
userInfo.user_agent = req.headers['user-agent'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取会话ID
|
||||||
|
if (req.headers['x-session-id']) {
|
||||||
|
userInfo.session_id = req.headers['x-session-id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 IP 地址
|
||||||
|
userInfo.ip = req.ip || req.connection?.remoteAddress;
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取请求参数(扁平化)
|
||||||
|
* @param {Object} reqBody - 请求体
|
||||||
|
* @returns {Object} 扁平化的请求参数
|
||||||
|
*/
|
||||||
|
export function extractRequestParams(reqBody) {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (reqBody?.temperature !== undefined) {
|
||||||
|
params.param_temperature = reqBody.temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqBody?.max_tokens !== undefined) {
|
||||||
|
params.param_max_tokens = reqBody.max_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqBody?.top_p !== undefined) {
|
||||||
|
params.param_top_p = reqBody.top_p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqBody?.stream !== undefined) {
|
||||||
|
params.param_stream = reqBody.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取消息摘要
|
||||||
|
* @param {Object} reqBody - 请求体
|
||||||
|
* @returns {Object} 消息摘要信息
|
||||||
|
*/
|
||||||
|
export function extractMessageSummary(reqBody) {
|
||||||
|
const summary = {};
|
||||||
|
|
||||||
|
// Anthropic API 格式 (/v1/messages)
|
||||||
|
if (reqBody?.messages && Array.isArray(reqBody.messages)) {
|
||||||
|
summary.message_count = reqBody.messages.length;
|
||||||
|
|
||||||
|
// 提取第一条消息的前100字符作为摘要
|
||||||
|
if (reqBody.messages.length > 0) {
|
||||||
|
const firstMsg = reqBody.messages[0];
|
||||||
|
if (firstMsg.content) {
|
||||||
|
if (typeof firstMsg.content === 'string') {
|
||||||
|
summary.first_message = firstMsg.content.substring(0, 100);
|
||||||
|
} else if (Array.isArray(firstMsg.content)) {
|
||||||
|
// 处理多模态内容
|
||||||
|
const textContent = firstMsg.content.find(c => c.type === 'text');
|
||||||
|
if (textContent?.text) {
|
||||||
|
summary.first_message = textContent.text.substring(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.first_message_role = firstMsg.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计角色分布(扁平化)
|
||||||
|
const roles = reqBody.messages.map(m => m.role);
|
||||||
|
summary.role_user_count = roles.filter(r => r === 'user').length;
|
||||||
|
summary.role_assistant_count = roles.filter(r => r === 'assistant').length;
|
||||||
|
summary.role_system_count = roles.filter(r => r === 'system').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// System prompt
|
||||||
|
if (reqBody?.system) {
|
||||||
|
summary.has_system_prompt = true;
|
||||||
|
summary.system_prompt_length = typeof reqBody.system === 'string'
|
||||||
|
? reqBody.system.length
|
||||||
|
: JSON.stringify(reqBody.system).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
if (reqBody?.tools && Array.isArray(reqBody.tools)) {
|
||||||
|
summary.tool_count = reqBody.tools.length;
|
||||||
|
// 将工具名数组转换为逗号分隔的字符串,最多记录10个
|
||||||
|
summary.tool_names = reqBody.tools.map(t => t.name).slice(0, 10).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取 Token 使用统计
|
||||||
|
* @param {Object} responseData - API 响应数据
|
||||||
|
* @returns {Object} Token 统计信息
|
||||||
|
*/
|
||||||
|
export function extractTokenUsage(responseData) {
|
||||||
|
const usage = {};
|
||||||
|
|
||||||
|
if (responseData?.usage) {
|
||||||
|
if (responseData.usage.input_tokens !== undefined) {
|
||||||
|
usage.input_tokens = responseData.usage.input_tokens;
|
||||||
|
}
|
||||||
|
if (responseData.usage.output_tokens !== undefined) {
|
||||||
|
usage.output_tokens = responseData.usage.output_tokens;
|
||||||
|
}
|
||||||
|
if (responseData.usage.total_tokens !== undefined) {
|
||||||
|
usage.total_tokens = responseData.usage.total_tokens;
|
||||||
|
}
|
||||||
|
// 缓存相关 tokens
|
||||||
|
if (responseData.usage.cache_creation_input_tokens !== undefined) {
|
||||||
|
usage.cache_creation_input_tokens = responseData.usage.cache_creation_input_tokens;
|
||||||
|
}
|
||||||
|
if (responseData.usage.cache_read_input_tokens !== undefined) {
|
||||||
|
usage.cache_read_input_tokens = responseData.usage.cache_read_input_tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建完整的日志对象
|
||||||
|
* @param {Object} options - 日志选项
|
||||||
|
* @param {string} options.method - HTTP 方法
|
||||||
|
* @param {string} options.endpoint - 请求端点
|
||||||
|
* @param {string} options.model - 模型 ID
|
||||||
|
* @param {number} options.status - 响应状态码
|
||||||
|
* @param {number} options.duration_ms - 请求耗时
|
||||||
|
* @param {Object} options.req - Express 请求对象
|
||||||
|
* @param {Object} [options.responseData] - 响应数据(可选,非流式响应时提供)
|
||||||
|
* @param {string} [options.error] - 错误信息(可选)
|
||||||
|
* @returns {Object} 完整的日志对象
|
||||||
|
*/
|
||||||
|
export function buildDetailedLog(options) {
|
||||||
|
const { method, endpoint, model, status, duration_ms, req, responseData, error } = options;
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
method,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
status,
|
||||||
|
duration_ms
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加错误信息
|
||||||
|
if (error) {
|
||||||
|
log.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取用户信息
|
||||||
|
if (req) {
|
||||||
|
const userInfo = extractUserInfo(req);
|
||||||
|
Object.assign(log, userInfo);
|
||||||
|
|
||||||
|
// 提取请求参数(已扁平化)
|
||||||
|
const params = extractRequestParams(req.body);
|
||||||
|
Object.assign(log, params);
|
||||||
|
|
||||||
|
// 提取消息摘要
|
||||||
|
const summary = extractMessageSummary(req.body);
|
||||||
|
if (Object.keys(summary).length > 0) {
|
||||||
|
Object.assign(log, summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 Token 使用统计
|
||||||
|
if (responseData) {
|
||||||
|
const tokenUsage = extractTokenUsage(responseData);
|
||||||
|
if (Object.keys(tokenUsage).length > 0) {
|
||||||
|
Object.assign(log, tokenUsage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加响应信息
|
||||||
|
if (responseData.id) {
|
||||||
|
log.response_id = responseData.id;
|
||||||
|
}
|
||||||
|
if (responseData.stop_reason) {
|
||||||
|
log.stop_reason = responseData.stop_reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
87
log-sanitizer.js
Normal file
87
log-sanitizer.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
const REDACTION = '[REDACTED]';
|
||||||
|
|
||||||
|
const REDACT_KEY_RE = /(authorization|x-api-key|api[-_]?key|access_token|refresh_token|client_secret|private_key|set-cookie|cookie|password|secret)/i;
|
||||||
|
const EMAIL_KEY_RE = /email/i;
|
||||||
|
const IP_KEY_RE = /(^ip$|ip_address|remote_address|x-forwarded-for)/i;
|
||||||
|
|
||||||
|
function maskEmail(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
return value.replace(/([A-Za-z0-9._%+-])([A-Za-z0-9._%+-]*)(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g, '$1***$3');
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskIp(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
let masked = value.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b/g, '$1.xxx');
|
||||||
|
masked = masked.replace(/\b([A-Fa-f0-9]{0,4}:){2,7}[A-Fa-f0-9]{0,4}\b/g, '****');
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskTokensInString(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
let masked = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+\b/g, 'Bearer ' + REDACTION);
|
||||||
|
masked = masked.replace(/\b(api_key|apikey|access_token|refresh_token|client_secret|password)=([^\s&]+)/gi, '$1=' + REDACTION);
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeString(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
let masked = value;
|
||||||
|
masked = maskTokensInString(masked);
|
||||||
|
masked = maskEmail(masked);
|
||||||
|
masked = maskIp(masked);
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeValue(value, key, seen) {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
|
||||||
|
if (key && REDACT_KEY_RE.test(key)) {
|
||||||
|
return REDACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (key && EMAIL_KEY_RE.test(key)) {
|
||||||
|
return maskEmail(value);
|
||||||
|
}
|
||||||
|
if (key && IP_KEY_RE.test(key)) {
|
||||||
|
return maskIp(value);
|
||||||
|
}
|
||||||
|
return sanitizeString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => sanitizeValue(item, key, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return sanitizeObject(value, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeObject(value, seen) {
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
if (!seen) seen = new WeakSet();
|
||||||
|
if (seen.has(value)) return '[Circular]';
|
||||||
|
seen.add(value);
|
||||||
|
|
||||||
|
const output = Array.isArray(value) ? [] : {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
output[key] = sanitizeValue(val, key, seen);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeForLog(value) {
|
||||||
|
return sanitizeValue(value, null, new WeakSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeLogMessage(message) {
|
||||||
|
if (typeof message !== 'string') return message;
|
||||||
|
return sanitizeString(message);
|
||||||
|
}
|
||||||
27
logger.js
27
logger.js
@@ -1,28 +1,29 @@
|
|||||||
import { isDevMode } from './config.js';
|
import { isDevMode } from './config.js';
|
||||||
|
import { sanitizeForLog, sanitizeLogMessage } from './log-sanitizer.js';
|
||||||
|
|
||||||
export function logInfo(message, data = null) {
|
export function logInfo(message, data = null) {
|
||||||
console.log(`[INFO] ${message}`);
|
console.log(`[INFO] ${sanitizeLogMessage(message)}`);
|
||||||
if (data && isDevMode()) {
|
if (data && isDevMode()) {
|
||||||
console.log(JSON.stringify(data, null, 2));
|
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logDebug(message, data = null) {
|
export function logDebug(message, data = null) {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.log(`[DEBUG] ${message}`);
|
console.log(`[DEBUG] ${sanitizeLogMessage(message)}`);
|
||||||
if (data) {
|
if (data) {
|
||||||
console.log(JSON.stringify(data, null, 2));
|
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logError(message, error = null) {
|
export function logError(message, error = null) {
|
||||||
console.error(`[ERROR] ${message}`);
|
console.error(`[ERROR] ${sanitizeLogMessage(message)}`);
|
||||||
if (error) {
|
if (error) {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.error(error);
|
console.error(sanitizeForLog(error));
|
||||||
} else {
|
} else {
|
||||||
console.error(error.message || error);
|
console.error(sanitizeLogMessage(error.message || String(error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,16 +31,16 @@ export function logError(message, error = null) {
|
|||||||
export function logRequest(method, url, headers = null, body = null) {
|
export function logRequest(method, url, headers = null, body = null) {
|
||||||
if (isDevMode()) {
|
if (isDevMode()) {
|
||||||
console.log(`\n${'='.repeat(80)}`);
|
console.log(`\n${'='.repeat(80)}`);
|
||||||
console.log(`[REQUEST] ${method} ${url}`);
|
console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`);
|
||||||
if (headers) {
|
if (headers) {
|
||||||
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
|
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
|
||||||
}
|
}
|
||||||
if (body) {
|
if (body) {
|
||||||
console.log('[BODY]', JSON.stringify(body, null, 2));
|
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
|
||||||
}
|
}
|
||||||
console.log('='.repeat(80) + '\n');
|
console.log('='.repeat(80) + '\n');
|
||||||
} else {
|
} else {
|
||||||
console.log(`[REQUEST] ${method} ${url}`);
|
console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +49,10 @@ export function logResponse(status, headers = null, body = null) {
|
|||||||
console.log(`\n${'-'.repeat(80)}`);
|
console.log(`\n${'-'.repeat(80)}`);
|
||||||
console.log(`[RESPONSE] Status: ${status}`);
|
console.log(`[RESPONSE] Status: ${status}`);
|
||||||
if (headers) {
|
if (headers) {
|
||||||
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
|
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
|
||||||
}
|
}
|
||||||
if (body) {
|
if (body) {
|
||||||
console.log('[BODY]', JSON.stringify(body, null, 2));
|
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
|
||||||
}
|
}
|
||||||
console.log('-'.repeat(80) + '\n');
|
console.log('-'.repeat(80) + '\n');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js"
|
"dev": "node server.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openai",
|
"openai",
|
||||||
|
|||||||
132
refresh-client.js
Normal file
132
refresh-client.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 15000;
|
||||||
|
const DEFAULT_MAX_RETRIES = 2;
|
||||||
|
const DEFAULT_RETRY_BASE_MS = 500;
|
||||||
|
const MAX_RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
function normalizeNumber(value, fallback) {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshConfig() {
|
||||||
|
return {
|
||||||
|
timeoutMs: normalizeNumber(process.env.DROID_REFRESH_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
|
||||||
|
maxRetries: normalizeNumber(process.env.DROID_REFRESH_RETRIES, DEFAULT_MAX_RETRIES),
|
||||||
|
retryDelayMs: normalizeNumber(process.env.DROID_REFRESH_RETRY_BASE_MS, DEFAULT_RETRY_BASE_MS)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
if (!ms || ms <= 0) return Promise.resolve();
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
if (error.name === 'AbortError') return true;
|
||||||
|
const retryCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND']);
|
||||||
|
return retryCodes.has(error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryStatus(status) {
|
||||||
|
return status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryAfterMs(response) {
|
||||||
|
if (!response?.headers?.get) return null;
|
||||||
|
const raw = response.headers.get('retry-after');
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const seconds = parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(seconds)) {
|
||||||
|
return Math.max(0, seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMs = Date.parse(raw);
|
||||||
|
if (!Number.isNaN(dateMs)) {
|
||||||
|
return Math.max(0, dateMs - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url, options, timeoutMs, fetchImpl) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetchImpl(url, { ...options, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildError(status, errorText) {
|
||||||
|
const message = `Failed to refresh token: ${status} ${errorText || ''}`.trim();
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = status;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestRefreshToken(options) {
|
||||||
|
const {
|
||||||
|
refreshUrl,
|
||||||
|
refreshToken,
|
||||||
|
clientId,
|
||||||
|
proxyAgentInfo,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
maxRetries = DEFAULT_MAX_RETRIES,
|
||||||
|
retryDelayMs = DEFAULT_RETRY_BASE_MS,
|
||||||
|
fetchImpl = fetch
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append('grant_type', 'refresh_token');
|
||||||
|
formData.append('refresh_token', refreshToken);
|
||||||
|
formData.append('client_id', clientId);
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: formData.toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (proxyAgentInfo?.agent) {
|
||||||
|
fetchOptions.agent = proxyAgentInfo.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithTimeout(refreshUrl, fetchOptions, timeoutMs, fetchImpl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
if (shouldRetryStatus(response.status) && attempt < maxRetries) {
|
||||||
|
const retryAfter = parseRetryAfterMs(response);
|
||||||
|
const delay = retryAfter ?? Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS);
|
||||||
|
attempt += 1;
|
||||||
|
await sleep(delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw buildError(response.status, errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (isRetryableError(error) && attempt < maxRetries) {
|
||||||
|
const delay = Math.min(retryDelayMs * (2 ** attempt), MAX_RETRY_DELAY_MS);
|
||||||
|
attempt += 1;
|
||||||
|
await sleep(delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
routes.js
224
routes.js
@@ -10,9 +10,80 @@ import { OpenAIResponseTransformer } from './transformers/response-openai.js';
|
|||||||
import { getApiKey } from './auth.js';
|
import { getApiKey } from './auth.js';
|
||||||
import { getNextProxyAgent } from './proxy-manager.js';
|
import { getNextProxyAgent } from './proxy-manager.js';
|
||||||
import { logRequest as slsLogRequest } from './sls-logger.js';
|
import { logRequest as slsLogRequest } from './sls-logger.js';
|
||||||
|
import { buildDetailedLog } from './log-extractor.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应内容伪装替换
|
||||||
|
* 将 Factory/Droid 相关词汇替换为 Anthropic/Claude,让用户感知为原生 Claude
|
||||||
|
*/
|
||||||
|
function maskResponseContent(text) {
|
||||||
|
if (!text || typeof text !== 'string') return text;
|
||||||
|
|
||||||
|
return text
|
||||||
|
// Factory -> Anthropic
|
||||||
|
.replace(/\bFactory\b/g, 'Anthropic')
|
||||||
|
.replace(/\bfactory\b/g, 'anthropic')
|
||||||
|
.replace(/\bFACTORY\b/g, 'ANTHROPIC')
|
||||||
|
// Droid -> Claude
|
||||||
|
.replace(/\bDroid\b/g, 'Claude')
|
||||||
|
.replace(/\bdroid\b/g, 'claude')
|
||||||
|
.replace(/\bDROID\b/g, 'CLAUDE');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归替换对象中的字符串内容
|
||||||
|
*/
|
||||||
|
function maskResponseObject(obj) {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return maskResponseContent(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(item => maskResponseObject(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
const masked = {};
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
masked[key] = maskResponseObject(obj[key]);
|
||||||
|
}
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 替换流式响应中的内容(SSE 格式)
|
||||||
|
*/
|
||||||
|
function maskStreamChunk(chunk) {
|
||||||
|
if (!chunk) return chunk;
|
||||||
|
|
||||||
|
const chunkStr = chunk.toString('utf-8');
|
||||||
|
|
||||||
|
// 对 SSE 数据行进行替换
|
||||||
|
return chunkStr.split('\n').map(line => {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const jsonPart = line.slice(6);
|
||||||
|
if (jsonPart === '[DONE]') return line;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonPart);
|
||||||
|
const masked = maskResponseObject(parsed);
|
||||||
|
return 'data: ' + JSON.stringify(masked);
|
||||||
|
} catch (e) {
|
||||||
|
// 非 JSON 数据,直接替换文本
|
||||||
|
return 'data: ' + maskResponseContent(jsonPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maskResponseContent(line);
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a /v1/responses API result to a /v1/chat/completions-compatible format.
|
* Convert a /v1/responses API result to a /v1/chat/completions-compatible format.
|
||||||
* Works for non-streaming responses.
|
* Works for non-streaming responses.
|
||||||
@@ -185,11 +256,18 @@ async function handleChatCompletions(req, res) {
|
|||||||
if (model.type === 'common') {
|
if (model.type === 'common') {
|
||||||
try {
|
try {
|
||||||
for await (const chunk of response.body) {
|
for await (const chunk of response.body) {
|
||||||
res.write(chunk);
|
res.write(maskStreamChunk(chunk));
|
||||||
}
|
}
|
||||||
res.end();
|
res.end();
|
||||||
logInfo('Stream forwarded (common type)');
|
logInfo('Stream forwarded (common type)');
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/chat/completions',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req
|
||||||
|
}));
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
logError('Stream error', streamError);
|
logError('Stream error', streamError);
|
||||||
res.end();
|
res.end();
|
||||||
@@ -205,11 +283,18 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of transformer.transformStream(response.body)) {
|
for await (const chunk of transformer.transformStream(response.body)) {
|
||||||
res.write(chunk);
|
res.write(maskStreamChunk(chunk));
|
||||||
}
|
}
|
||||||
res.end();
|
res.end();
|
||||||
logInfo('Stream completed');
|
logInfo('Stream completed');
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/chat/completions',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req
|
||||||
|
}));
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
logError('Stream error', streamError);
|
logError('Stream error', streamError);
|
||||||
res.end();
|
res.end();
|
||||||
@@ -220,24 +305,43 @@ async function handleChatCompletions(req, res) {
|
|||||||
if (model.type === 'openai') {
|
if (model.type === 'openai') {
|
||||||
try {
|
try {
|
||||||
const converted = convertResponseToChatCompletion(data);
|
const converted = convertResponseToChatCompletion(data);
|
||||||
logResponse(200, null, converted);
|
const maskedConverted = maskResponseObject(converted);
|
||||||
res.json(converted);
|
logResponse(200, null, maskedConverted);
|
||||||
|
res.json(maskedConverted);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 如果转换失败,回退为原始数据
|
// 如果转换失败,回退为原始数据
|
||||||
logResponse(200, null, data);
|
const maskedData = maskResponseObject(data);
|
||||||
res.json(data);
|
logResponse(200, null, maskedData);
|
||||||
|
res.json(maskedData);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// anthropic/common: 保持现有逻辑,直接转发
|
// anthropic/common: 保持现有逻辑,直接转发
|
||||||
logResponse(200, null, data);
|
const maskedData = maskResponseObject(data);
|
||||||
res.json(data);
|
logResponse(200, null, maskedData);
|
||||||
|
res.json(maskedData);
|
||||||
}
|
}
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/chat/completions',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
responseData: data
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Error in /v1/chat/completions', error);
|
logError('Error in /v1/chat/completions', error);
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/chat/completions', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/chat/completions',
|
||||||
|
model: req.body?.model,
|
||||||
|
status: 500,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
error: error.message
|
||||||
|
}));
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: error.message
|
message: error.message
|
||||||
@@ -390,26 +494,50 @@ async function handleDirectResponses(req, res) {
|
|||||||
try {
|
try {
|
||||||
// 直接将原始响应流转发给客户端
|
// 直接将原始响应流转发给客户端
|
||||||
for await (const chunk of response.body) {
|
for await (const chunk of response.body) {
|
||||||
res.write(chunk);
|
res.write(maskStreamChunk(chunk));
|
||||||
}
|
}
|
||||||
res.end();
|
res.end();
|
||||||
logInfo('Stream forwarded successfully');
|
logInfo('Stream forwarded successfully');
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/responses', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/responses',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req
|
||||||
|
}));
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
logError('Stream error', streamError);
|
logError('Stream error', streamError);
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 直接转发非流式响应,不做任何转换
|
// 直接转发非流式响应
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
logResponse(200, null, data);
|
const maskedData = maskResponseObject(data);
|
||||||
res.json(data);
|
logResponse(200, null, maskedData);
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/responses', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
res.json(maskedData);
|
||||||
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/responses',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
responseData: data
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Error in /v1/responses', error);
|
logError('Error in /v1/responses', error);
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/responses', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/responses',
|
||||||
|
model: req.body?.model,
|
||||||
|
status: 500,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
error: error.message
|
||||||
|
}));
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: error.message
|
message: error.message
|
||||||
@@ -627,26 +755,50 @@ async function handleDirectMessages(req, res) {
|
|||||||
try {
|
try {
|
||||||
// 直接将原始响应流转发给客户端
|
// 直接将原始响应流转发给客户端
|
||||||
for await (const chunk of response.body) {
|
for await (const chunk of response.body) {
|
||||||
res.write(chunk);
|
res.write(maskStreamChunk(chunk));
|
||||||
}
|
}
|
||||||
res.end();
|
res.end();
|
||||||
logInfo('Stream forwarded successfully');
|
logInfo('Stream forwarded successfully');
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/messages', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/messages',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req
|
||||||
|
}));
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
logError('Stream error', streamError);
|
logError('Stream error', streamError);
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 直接转发非流式响应,不做任何转换
|
// 直接转发非流式响应
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
logResponse(200, null, data);
|
const maskedData = maskResponseObject(data);
|
||||||
res.json(data);
|
logResponse(200, null, maskedData);
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/messages', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
res.json(maskedData);
|
||||||
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/messages',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
responseData: data
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Error in /v1/messages', error);
|
logError('Error in /v1/messages', error);
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/messages', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/messages',
|
||||||
|
model: req.body?.model,
|
||||||
|
status: 500,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
error: error.message
|
||||||
|
}));
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: error.message
|
message: error.message
|
||||||
@@ -743,11 +895,27 @@ async function handleCountTokens(req, res) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
logResponse(200, null, data);
|
logResponse(200, null, data);
|
||||||
res.json(data);
|
res.json(data);
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/messages/count_tokens', model: modelId, status: 200, duration_ms: Date.now() - startTime });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/messages/count_tokens',
|
||||||
|
model: modelId,
|
||||||
|
status: 200,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
responseData: data
|
||||||
|
}));
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Error in /v1/messages/count_tokens', error);
|
logError('Error in /v1/messages/count_tokens', error);
|
||||||
slsLogRequest({ method: 'POST', endpoint: '/v1/messages/count_tokens', model: req.body?.model, status: 500, duration_ms: Date.now() - startTime, error: error.message });
|
slsLogRequest(buildDetailedLog({
|
||||||
|
method: 'POST',
|
||||||
|
endpoint: '/v1/messages/count_tokens',
|
||||||
|
model: req.body?.model,
|
||||||
|
status: 500,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
req,
|
||||||
|
error: error.message
|
||||||
|
}));
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: error.message
|
message: error.message
|
||||||
|
|||||||
165
server.js
165
server.js
@@ -1,26 +1,156 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { loadConfig, isDevMode, getPort } from './config.js';
|
import { loadConfig, isDevMode, getPort, getCorsConfig } from './config.js';
|
||||||
import { logInfo, logError } from './logger.js';
|
import { logInfo, logError } from './logger.js';
|
||||||
import router from './routes.js';
|
import router from './routes.js';
|
||||||
import { initializeAuth } from './auth.js';
|
import { initializeAuth } from './auth.js';
|
||||||
import { initializeUserAgentUpdater } from './user-agent-updater.js';
|
import { initializeUserAgentUpdater } from './user-agent-updater.js';
|
||||||
|
import './sls-logger.js'; // 初始化阿里云日志服务
|
||||||
|
import { sanitizeForLog } from './log-sanitizer.js';
|
||||||
|
import { authMiddleware, getAuthConfig } from './auth-middleware.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 全局错误处理 - 必须在应用启动前注册
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let isShuttingDown = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优雅关闭服务器
|
||||||
|
*/
|
||||||
|
function gracefulShutdown(reason, exitCode = 1) {
|
||||||
|
if (isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isShuttingDown = true;
|
||||||
|
|
||||||
|
console.error(`\n${'='.repeat(80)}`);
|
||||||
|
console.error(`🛑 Server shutting down: ${reason}`);
|
||||||
|
console.error(`${'='.repeat(80)}\n`);
|
||||||
|
|
||||||
|
// 给正在处理的请求一些时间完成
|
||||||
|
setTimeout(() => {
|
||||||
|
process.exit(exitCode);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理未捕获的 Promise Rejection
|
||||||
|
*/
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
const errorInfo = {
|
||||||
|
type: 'unhandledRejection',
|
||||||
|
reason: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`\n${'='.repeat(80)}`);
|
||||||
|
console.error('⚠️ Unhandled Promise Rejection');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error(`Time: ${errorInfo.timestamp}`);
|
||||||
|
console.error(`Reason: ${errorInfo.reason}`);
|
||||||
|
if (errorInfo.stack && isDevMode()) {
|
||||||
|
console.error(`Stack: ${errorInfo.stack}`);
|
||||||
|
}
|
||||||
|
console.error('='.repeat(80) + '\n');
|
||||||
|
|
||||||
|
logError('Unhandled Promise Rejection', sanitizeForLog(errorInfo));
|
||||||
|
|
||||||
|
// 在生产环境中,unhandledRejection 可能表示严重问题,考虑退出
|
||||||
|
// 但为了服务稳定性,这里只记录不退出
|
||||||
|
// 如需更严格的处理,可取消下面的注释:
|
||||||
|
// gracefulShutdown('Unhandled Promise Rejection', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理未捕获的异常
|
||||||
|
*/
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
const errorInfo = {
|
||||||
|
type: 'uncaughtException',
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`\n${'='.repeat(80)}`);
|
||||||
|
console.error('💥 Uncaught Exception');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error(`Time: ${errorInfo.timestamp}`);
|
||||||
|
console.error(`Error: ${errorInfo.message}`);
|
||||||
|
if (errorInfo.stack) {
|
||||||
|
console.error(`Stack: ${errorInfo.stack}`);
|
||||||
|
}
|
||||||
|
console.error('='.repeat(80) + '\n');
|
||||||
|
|
||||||
|
logError('Uncaught Exception', sanitizeForLog(errorInfo));
|
||||||
|
|
||||||
|
// uncaughtException 后进程状态不确定,必须退出
|
||||||
|
gracefulShutdown('Uncaught Exception', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理进程信号
|
||||||
|
*/
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logInfo('Received SIGTERM signal');
|
||||||
|
gracefulShutdown('SIGTERM', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
logInfo('Received SIGINT signal');
|
||||||
|
gracefulShutdown('SIGINT', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS 中间件 - 根据配置动态处理跨域请求
|
||||||
|
*/
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
const corsConfig = getCorsConfig();
|
||||||
|
|
||||||
|
// 如果 CORS 完全禁用,直接跳过
|
||||||
|
if (!corsConfig.enabled) {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
|
||||||
|
// 设置允许的方法和头
|
||||||
|
res.header('Access-Control-Allow-Methods', corsConfig.methods.join(', '));
|
||||||
|
res.header('Access-Control-Allow-Headers', corsConfig.headers.join(', '));
|
||||||
|
|
||||||
|
if (corsConfig.allowAll) {
|
||||||
|
// 允许所有来源(仅用于开发环境)
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
} else if (origin && corsConfig.origins.length > 0) {
|
||||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, anthropic-version');
|
// 白名单模式:验证请求来源
|
||||||
|
if (corsConfig.origins.includes(origin)) {
|
||||||
|
res.header('Access-Control-Allow-Origin', origin);
|
||||||
|
res.header('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
// 不在白名单中的请求不设置 CORS 头,浏览器会拒绝
|
||||||
|
}
|
||||||
|
// 如果没有配置 origins 且不是 allowAll,则不设置任何 CORS 头
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return res.sendStatus(200);
|
return res.sendStatus(204);
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 请求认证中间件
|
||||||
|
app.use(authMiddleware);
|
||||||
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
@@ -57,6 +187,11 @@ app.use((req, res, next) => {
|
|||||||
ip: req.ip || req.connection.remoteAddress
|
ip: req.ip || req.connection.remoteAddress
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const safeQuery = sanitizeForLog(errorInfo.query);
|
||||||
|
const safeBody = sanitizeForLog(errorInfo.body);
|
||||||
|
const safeHeaders = sanitizeForLog(errorInfo.headers);
|
||||||
|
const safeIp = sanitizeForLog(errorInfo.ip);
|
||||||
|
|
||||||
console.error('\n' + '='.repeat(80));
|
console.error('\n' + '='.repeat(80));
|
||||||
console.error('❌ 非法请求地址');
|
console.error('❌ 非法请求地址');
|
||||||
console.error('='.repeat(80));
|
console.error('='.repeat(80));
|
||||||
@@ -66,23 +201,23 @@ app.use((req, res, next) => {
|
|||||||
console.error(`路径: ${errorInfo.path}`);
|
console.error(`路径: ${errorInfo.path}`);
|
||||||
|
|
||||||
if (Object.keys(errorInfo.query).length > 0) {
|
if (Object.keys(errorInfo.query).length > 0) {
|
||||||
console.error(`查询参数: ${JSON.stringify(errorInfo.query, null, 2)}`);
|
console.error(`查询参数: ${JSON.stringify(safeQuery, null, 2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorInfo.body && Object.keys(errorInfo.body).length > 0) {
|
if (errorInfo.body && Object.keys(errorInfo.body).length > 0) {
|
||||||
console.error(`请求体: ${JSON.stringify(errorInfo.body, null, 2)}`);
|
console.error(`请求体: ${JSON.stringify(safeBody, null, 2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`客户端IP: ${errorInfo.ip}`);
|
console.error(`客户端IP: ${safeIp}`);
|
||||||
console.error(`User-Agent: ${errorInfo.headers['user-agent'] || 'N/A'}`);
|
console.error(`User-Agent: ${safeHeaders['user-agent'] || 'N/A'}`);
|
||||||
|
|
||||||
if (errorInfo.headers.referer) {
|
if (safeHeaders.referer) {
|
||||||
console.error(`来源: ${errorInfo.headers.referer}`);
|
console.error(`来源: ${safeHeaders.referer}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('='.repeat(80) + '\n');
|
console.error('='.repeat(80) + '\n');
|
||||||
|
|
||||||
logError('Invalid request path', errorInfo);
|
logError('Invalid request path', sanitizeForLog(errorInfo));
|
||||||
|
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
error: 'Not Found',
|
error: 'Not Found',
|
||||||
@@ -113,6 +248,14 @@ app.use((err, req, res, next) => {
|
|||||||
logInfo('Configuration loaded successfully');
|
logInfo('Configuration loaded successfully');
|
||||||
logInfo(`Dev mode: ${isDevMode()}`);
|
logInfo(`Dev mode: ${isDevMode()}`);
|
||||||
|
|
||||||
|
// Log auth status
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
if (authConfig.enabled) {
|
||||||
|
logInfo(`Auth enabled with ${authConfig.apiKeys.size} API key(s)`);
|
||||||
|
} else {
|
||||||
|
logInfo('Auth disabled - API endpoints are publicly accessible');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize User-Agent version updater
|
// Initialize User-Agent version updater
|
||||||
initializeUserAgentUpdater();
|
initializeUserAgentUpdater();
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
* - 环境变量缺失时静默降级
|
* - 环境变量缺失时静默降级
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import aliyunLog from 'aliyun-log';
|
import ALSClient from 'aliyun-log';
|
||||||
const { Client, PutLogsRequest, LogItem, LogContent } = aliyunLog;
|
import { sanitizeForLog } from './log-sanitizer.js';
|
||||||
|
|
||||||
// SLS 配置
|
// SLS 配置
|
||||||
const SLS_CONFIG = {
|
const SLS_CONFIG = {
|
||||||
@@ -19,8 +19,22 @@ const SLS_CONFIG = {
|
|||||||
logstore: process.env.ALIYUN_SLS_LOGSTORE
|
logstore: process.env.ALIYUN_SLS_LOGSTORE
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveSlsEnabled() {
|
||||||
|
const raw = process.env.SLS_ENABLED;
|
||||||
|
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||||
|
return process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = String(raw).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(value)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(value)) return false;
|
||||||
|
return process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLS_ENABLED = resolveSlsEnabled();
|
||||||
|
|
||||||
// 检查配置是否完整
|
// 检查配置是否完整
|
||||||
const isConfigured = Object.values(SLS_CONFIG).every(v => v);
|
const isConfigured = SLS_ENABLED && Object.values(SLS_CONFIG).every(v => v);
|
||||||
|
|
||||||
let client = null;
|
let client = null;
|
||||||
let logQueue = [];
|
let logQueue = [];
|
||||||
@@ -29,13 +43,16 @@ const FLUSH_INTERVAL_MS = 5000;
|
|||||||
|
|
||||||
// 初始化 SLS Client
|
// 初始化 SLS Client
|
||||||
function initClient() {
|
function initClient() {
|
||||||
|
if (!SLS_ENABLED) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台');
|
console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
client = new Client({
|
client = new ALSClient({
|
||||||
accessKeyId: SLS_CONFIG.accessKeyId,
|
accessKeyId: SLS_CONFIG.accessKeyId,
|
||||||
accessKeySecret: SLS_CONFIG.accessKeySecret,
|
accessKeySecret: SLS_CONFIG.accessKeySecret,
|
||||||
endpoint: SLS_CONFIG.endpoint
|
endpoint: SLS_CONFIG.endpoint
|
||||||
@@ -55,28 +72,14 @@ async function flushLogs() {
|
|||||||
const logsToSend = logQueue.splice(0, BATCH_SIZE);
|
const logsToSend = logQueue.splice(0, BATCH_SIZE);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const logItems = logsToSend.map(log => {
|
const logs = logsToSend.map(log => ({
|
||||||
const contents = Object.entries(log).map(([key, value]) => {
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
return new LogContent({
|
content: Object.fromEntries(
|
||||||
key,
|
Object.entries(log).map(([key, value]) => [key, String(value ?? '')])
|
||||||
value: String(value ?? '')
|
)
|
||||||
});
|
}));
|
||||||
});
|
|
||||||
return new LogItem({
|
|
||||||
time: Math.floor(Date.now() / 1000),
|
|
||||||
contents
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = new PutLogsRequest({
|
await client.postLogStoreLogs(SLS_CONFIG.project, SLS_CONFIG.logstore, { logs });
|
||||||
projectName: SLS_CONFIG.project,
|
|
||||||
logStoreName: SLS_CONFIG.logstore,
|
|
||||||
logGroup: {
|
|
||||||
logs: logItems
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.putLogs(request);
|
|
||||||
console.log(`[SLS] 成功上报 ${logsToSend.length} 条日志`);
|
console.log(`[SLS] 成功上报 ${logsToSend.length} 条日志`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SLS] 日志上报失败:', error.message);
|
console.error('[SLS] 日志上报失败:', error.message);
|
||||||
@@ -88,7 +91,7 @@ async function flushLogs() {
|
|||||||
// 定时刷新
|
// 定时刷新
|
||||||
let flushTimer = null;
|
let flushTimer = null;
|
||||||
function startFlushTimer() {
|
function startFlushTimer() {
|
||||||
if (flushTimer || !isConfigured) return;
|
if (!SLS_ENABLED || flushTimer || !isConfigured) return;
|
||||||
flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS);
|
flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +108,11 @@ function startFlushTimer() {
|
|||||||
* @param {string} [logData.error] - 错误信息
|
* @param {string} [logData.error] - 错误信息
|
||||||
*/
|
*/
|
||||||
export function logRequest(logData) {
|
export function logRequest(logData) {
|
||||||
const enrichedLog = {
|
if (!SLS_ENABLED) return;
|
||||||
|
const enrichedLog = sanitizeForLog({
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
...logData
|
...logData
|
||||||
};
|
});
|
||||||
|
|
||||||
// 始终输出到控制台
|
// 始终输出到控制台
|
||||||
console.log('[SLS]', JSON.stringify(enrichedLog));
|
console.log('[SLS]', JSON.stringify(enrichedLog));
|
||||||
@@ -127,6 +131,7 @@ export function logRequest(logData) {
|
|||||||
* 优雅关闭,刷新剩余日志
|
* 优雅关闭,刷新剩余日志
|
||||||
*/
|
*/
|
||||||
export async function shutdown() {
|
export async function shutdown() {
|
||||||
|
if (!SLS_ENABLED) return;
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearInterval(flushTimer);
|
clearInterval(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
|
|||||||
@@ -17,9 +17,19 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# ========== 配置区域 ==========
|
# ========== 配置区域 ==========
|
||||||
# 可以修改这些默认值,或通过命令行参数覆盖
|
# 优先从 .env 文件读取,可通过环境变量或命令行参数覆盖
|
||||||
DEFAULT_SERVER="user@your-server.com"
|
|
||||||
DEFAULT_REMOTE_PATH="/opt/droid2api"
|
# 加载 .env 文件(如果存在)
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||||
|
set -a # 自动导出变量
|
||||||
|
source "$SCRIPT_DIR/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 配置项(优先级:命令行参数 > 环境变量 > 默认值)
|
||||||
|
DEFAULT_SERVER="${SYNC_SERVER:-user@your-server.com}"
|
||||||
|
DEFAULT_REMOTE_PATH="${SYNC_REMOTE_PATH:-/opt/droid2api}"
|
||||||
LOCAL_FILE="accounts.json"
|
LOCAL_FILE="accounts.json"
|
||||||
|
|
||||||
# 部署方式: pm2 | docker | docker-compose | none
|
# 部署方式: pm2 | docker | docker-compose | none
|
||||||
@@ -95,6 +105,41 @@ else
|
|||||||
REMOTE_COUNT=0
|
REMOTE_COUNT=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Step 1.5: 检测 Docker 镜像版本(仅 docker-compose 模式)
|
||||||
|
if [[ "$DEPLOY_TYPE" == "docker-compose" ]]; then
|
||||||
|
log_info "正在检测远程镜像版本..."
|
||||||
|
|
||||||
|
# 检查远程 auth.js 是否包含多账号检测逻辑
|
||||||
|
MULTI_ACCOUNT_SUPPORT=$(ssh "$SERVER" "docker exec $DOCKER_SERVICE_NAME cat /app/auth.js 2>/dev/null | grep -c 'accounts.json' || echo 0" 2>/dev/null || echo "error")
|
||||||
|
|
||||||
|
if [[ "$MULTI_ACCOUNT_SUPPORT" == "error" || "$MULTI_ACCOUNT_SUPPORT" == "" ]]; then
|
||||||
|
log_warn "无法检测镜像版本(容器可能未运行)"
|
||||||
|
elif [[ "$MULTI_ACCOUNT_SUPPORT" == "0" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
log_error "⚠️ 远程镜像版本过旧,不支持多账号功能!"
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
log_info "请在服务器上执行以下命令更新镜像:"
|
||||||
|
echo ""
|
||||||
|
echo " cd $REMOTE_PATH"
|
||||||
|
echo " git pull origin main"
|
||||||
|
echo " docker compose build --no-cache"
|
||||||
|
echo " docker compose up -d"
|
||||||
|
echo ""
|
||||||
|
log_info "更新完成后,重新运行本脚本同步配置。"
|
||||||
|
echo ""
|
||||||
|
read -p "是否继续同步配置?(不推荐) (y/N) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
log_warn "已取消同步"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_success "远程镜像支持多账号功能"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Step 2: 显示本地账号数量
|
# Step 2: 显示本地账号数量
|
||||||
LOCAL_COUNT=$(jq '.accounts | length' "$LOCAL_FILE")
|
LOCAL_COUNT=$(jq '.accounts | length' "$LOCAL_FILE")
|
||||||
log_info "本地现有 $LOCAL_COUNT 个账号"
|
log_info "本地现有 $LOCAL_COUNT 个账号"
|
||||||
@@ -171,7 +216,7 @@ case "$DEPLOY_TYPE" in
|
|||||||
;;
|
;;
|
||||||
docker-compose)
|
docker-compose)
|
||||||
log_info "正在重启 Docker Compose 服务..."
|
log_info "正在重启 Docker Compose 服务..."
|
||||||
ssh "$SERVER" "cd $REMOTE_PATH && docker-compose restart" 2>/dev/null && \
|
ssh "$SERVER" "cd $REMOTE_PATH && docker compose restart" 2>/dev/null && \
|
||||||
log_success "Docker Compose 服务已重启" || \
|
log_success "Docker Compose 服务已重启" || \
|
||||||
log_warn "Docker Compose 重启失败,请手动重启"
|
log_warn "Docker Compose 重启失败,请手动重启"
|
||||||
;;
|
;;
|
||||||
|
|||||||
112
tests/refresh-client.test.js
Normal file
112
tests/refresh-client.test.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { requestRefreshToken } from '../refresh-client.js';
|
||||||
|
|
||||||
|
function mockResponse({ ok, status, jsonData, textData, headers }) {
|
||||||
|
const normalizedHeaders = {};
|
||||||
|
if (headers) {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
normalizedHeaders[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => jsonData ?? {},
|
||||||
|
text: async () => textData ?? '',
|
||||||
|
headers: {
|
||||||
|
get: (name) => normalizedHeaders[name.toLowerCase()] ?? null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refresh retries on 500 then succeeds', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {
|
||||||
|
return mockResponse({ ok: false, status: 500, textData: 'boom' });
|
||||||
|
}
|
||||||
|
return mockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { access_token: 'access', refresh_token: 'refresh' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await requestRefreshToken({
|
||||||
|
refreshUrl: 'https://example.com',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
clientId: 'client',
|
||||||
|
timeoutMs: 20,
|
||||||
|
maxRetries: 1,
|
||||||
|
retryDelayMs: 1,
|
||||||
|
fetchImpl
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(data.access_token, 'access');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh does not retry on 400', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = async () => {
|
||||||
|
calls += 1;
|
||||||
|
return mockResponse({ ok: false, status: 400, textData: 'bad request' });
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => requestRefreshToken({
|
||||||
|
refreshUrl: 'https://example.com',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
clientId: 'client',
|
||||||
|
timeoutMs: 20,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 1,
|
||||||
|
fetchImpl
|
||||||
|
}),
|
||||||
|
(err) => err?.status === 400
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh retries on timeout abort', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
calls += 1;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!options?.signal) {
|
||||||
|
resolve(mockResponse({ ok: true, status: 200, jsonData: {} }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
const error = new Error('Aborted');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
const error = new Error('Aborted');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => requestRefreshToken({
|
||||||
|
refreshUrl: 'https://example.com',
|
||||||
|
refreshToken: 'refresh_token',
|
||||||
|
clientId: 'client',
|
||||||
|
timeoutMs: 5,
|
||||||
|
maxRetries: 1,
|
||||||
|
retryDelayMs: 1,
|
||||||
|
fetchImpl
|
||||||
|
}),
|
||||||
|
(err) => err?.name === 'AbortError'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
@@ -13,9 +13,9 @@ let isUpdating = false;
|
|||||||
|
|
||||||
function getDefaultVersion() {
|
function getDefaultVersion() {
|
||||||
const cfg = getConfig();
|
const cfg = getConfig();
|
||||||
const userAgent = cfg.user_agent || 'factory-cli/0.19.3';
|
const userAgent = cfg.user_agent || 'factory-cli/0.40.2';
|
||||||
const match = userAgent.match(/\/(\d+\.\d+\.\d+)/);
|
const match = userAgent.match(/\/(\d+\.\d+\.\d+)/);
|
||||||
return match ? match[1] : '0.19.3';
|
return match ? match[1] : '0.40.2';
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeVersion() {
|
function initializeVersion() {
|
||||||
|
|||||||
Reference in New Issue
Block a user