Compare commits

...

41 Commits

Author SHA1 Message Date
empty
906ac686a2 feat: 添加生产环境专用 docker-compose.prod.yml
- 使用预构建镜像 image: droid2api:latest
- 适用于离线部署场景
- 本地开发保留 build: .
2025-12-27 17:04:40 +08:00
empty
3670aceb4a fix: 修复 Dockerfile 构建问题
- Node 24 → Node 20(更稳定)
- --only=production → --omit=dev(新语法)
2025-12-27 17:00:07 +08:00
empty
0504029e47 feat: 添加共享网络配置支持独立部署
- 配置 api-network 外部网络
- 支持与 new-api 独立部署但可互通
- 添加 AUTH_ENABLED 和 API_KEYS 环境变量
2025-12-27 16:46:50 +08:00
empty
51e4b3a839 docs: 更新 API 认证配置说明
- 添加 new-api/one-api 接入场景说明
- 明确两层安全验证流程
2025-12-27 16:24:26 +08:00
empty
d1dc095cb1 feat: 添加请求认证中间件保护 API 端点
- 新增 auth-middleware.js 验证客户端 API Key
- 支持 Authorization: Bearer <key> 和 x-api-key 两种方式
- API Keys 只通过环境变量配置(安全最佳实践)
- 公开路径: /, /health, /status
- 可配置 /v1/models 是否需要认证
- 启动时输出认证状态日志

配置方式:
  AUTH_ENABLED=true
  API_KEYS=sk-key1,sk-key2

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 16:14:15 +08:00
Claude Code
17ddd815a9 chore: 将 Cloudflare Tunnel 设为可选服务
使用 Docker Compose profiles 功能,默认不启动 tunnel 服务
启用方式: docker compose --profile tunnel up -d

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 08:03:15 +00:00
empty
df1ac40d41 chore: 添加 .serena/ 到 .gitignore
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:53:42 +08:00
empty
cc4e4cea94 fix: 修正 system_prompt 中的名称为 Droid
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:49:32 +08:00
empty
8068475d6e fix: 恢复误修改的 user_agent 和 system_prompt 配置
CORS 修复时不慎修改了无关字段,现恢复原值:
- user_agent: factory-cli/0.40.2
- system_prompt: You are Droid...built by Factory

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:36:39 +08:00
empty
eef909c5dd feat: 实现可配置的 CORS 安全策略
- 添加 getCorsConfig() 函数支持灵活的 CORS 配置
- 支持三种模式:禁用 CORS、白名单、允许所有来源
- 环境变量可覆盖 config.json 配置 (CORS_ENABLED, CORS_ALLOW_ALL, CORS_ORIGINS)
- config.json 默认使用白名单模式,仅允许 localhost
- 动态验证 Origin 头,不在白名单的请求不设置 CORS 头
- 添加 Vary: Origin 头支持 CDN 缓存

安全改进:
- 生产环境默认 allow_all=false,避免 CORS 通配符
- 白名单模式下,未授权来源的请求会被浏览器拒绝

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:33:04 +08:00
empty
3dccbcfed1 feat: 添加全局错误处理机制
- 添加 unhandledRejection 处理器捕获未处理的 Promise rejection
- 添加 uncaughtException 处理器捕获未捕获的异常
- 添加 SIGTERM/SIGINT 信号处理实现优雅关闭
- 实现 gracefulShutdown 函数,给正在处理的请求3秒完成时间
- 错误信息经过 sanitizeForLog 脱敏处理
- 生产环境下隐藏堆栈信息

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:24:03 +08:00
empty
ed888edfc9 feat: 增强 Token 过期验证机制
- 添加 tokenExpiresAt 状态变量追踪实际过期时间
- saveTokens() 保存 expires_at 字段到文件
- loadAuthConfig() 启动时验证 token 是否过期
- shouldRefresh() 优先使用实际过期时间判断
- 提前 30 分钟刷新避免临界问题
- 修复 refreshApiKey() 中的代码缩进问题

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:22:01 +08:00
empty
42fc3f2cf3 feat: 集成 Cloudflare Tunnel 支持
- docker-compose.yml 添加 cloudflared 服务
- 更新 .env.example 和 README.md 添加配置说明
- 支持通过 Cloudflare Tunnel 进行安全部署
2025-12-27 15:14:12 +08:00
empty
a18e45ee78 feat: add refresh retry/timeout and tests 2025-12-27 15:07:54 +08:00
empty
5e01993120 feat: add SLS toggle and sanitize logs 2025-12-27 15:07:28 +08:00
empty
b186f9b80e docs: 更新文档与代码实现保持一致
- 添加 xhigh 推理级别 (40960 tokens)
- 更新授权优先级为四级 (accounts.json > FACTORY_API_KEY > refresh_token > 客户端)
- 添加 common 模型类型说明 (Gemini, GLM 等)
- 更新 config.json 示例,添加 model_redirects/provider/user_agent 字段
- 添加 aliyun-log 依赖说明
- DOCKER_DEPLOY.md 添加阿里云日志服务环境变量说明

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:12:36 +08:00
Claude Code
c5efebb805 chore: 切换到生产模式并清理配置
- 移除 docker-compose.yml 中已过时的 version 字段
- 将 dev_mode 设置为 false
- 添加 accounts.json 加载调试日志

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:00:18 +00:00
empty
8aa8021d61 chore: 更新 user-agent 版本为 0.40.2
- user-agent-updater.js 默认值
- config.json 配置值
2025-12-27 13:58:32 +08:00
empty
5bdbc35875 feat: sync-accounts.sh 添加 Docker 镜像版本检测
- 检测远程容器是否支持多账号功能
- 版本过旧时给出更新镜像的具体命令
- 避免配置同步后无法生效的问题
2025-12-27 13:54:22 +08:00
empty
9200e912fd feat: 添加响应内容伪装替换功能
- 将 Droid 替换为 Claude
- 将 Factory 替换为 Anthropic
- 支持流式和非流式响应
- 让用户感知为原生 Claude 服务
2025-12-27 13:26:18 +08:00
empty
fecd215719 docs: 添加多账号 OAuth 支持文档
- 添加 add-account.js 和 sync-accounts.sh 使用说明
- 说明配置优先级和账号管理特性
- accounts.json 添加到 .gitignore
2025-12-27 12:59:28 +08:00
empty
dd58dec1f5 feat: 添加 accounts.json 卷挂载
- 确保 Docker 重启后能加载最新的多账号配置
- 使用 :ro 只读挂载,配置文件由 sync-accounts.sh 管理
2025-12-27 12:55:06 +08:00
empty
d3fe5dc92a fix: 更新 docker compose 命令格式
- docker-compose 已弃用,改为 docker compose
2025-12-27 12:53:42 +08:00
empty
52ea5945eb refactor: sync-accounts.sh 配置改为从 .env 读取
- 自动加载脚本目录下的 .env 文件
- 新增 SYNC_SERVER, SYNC_REMOTE_PATH 等环境变量
- 更新 .env.example 添加同步配置示例
2025-12-27 12:47:08 +08:00
Claude Code
5050a8c764 Merge branch 'main' of git.let5see.xyz:let5see/droid2api 2025-12-27 04:43:13 +00:00
Claude Code
82a5a2cdfb feat: 集成阿里云日志服务(SLS)并增强日志记录详情
- 添加 SLS 日志上报模块(sls-logger.js)
  - 支持批量上报(每10条或5秒间隔)
  - 环境变量缺失时静默降级
  - 自动重试失败的日志

- 新增日志信息提取器(log-extractor.js)
  - 提取 Token 使用统计(input_tokens, output_tokens)
  - 提取用户标识信息(user_id, session_id, ip)
  - 提取请求参数(temperature, max_tokens, stream)
  - 提取消息摘要(message_count, role_distribution, tool_names)

- 增强所有 API 端点的日志记录
  - /v1/chat/completions
  - /v1/responses
  - /v1/messages
  - /v1/messages/count_tokens

- 修复日志字段序列化问题
  - 扁平化嵌套对象字段,避免 [object Object]
  - 数组字段转换为逗号分隔字符串

- 添加阿里云环境变量配置到 docker-compose.yml
  - ALIYUN_ACCESS_KEY_ID
  - ALIYUN_ACCESS_KEY_SECRET
  - ALIYUN_SLS_ENDPOINT
  - ALIYUN_SLS_PROJECT
  - ALIYUN_SLS_LOGSTORE

- 修改认证配置为自动刷新 Token 机制
  - 使用 DROID_REFRESH_KEY 替代固定的 FACTORY_API_KEY
  - 实现每6小时自动刷新(Token有效期8小时)
  - Token 持久化到 auth.json
2025-12-27 04:42:43 +00:00
empty
db5fc39072 feat: 添加账号配置增量同步脚本
- 支持 SSH 方式安全同步到远程服务器
- 支持 PM2/Docker/Docker Compose 多种部署方式
- 按 email 增量合并,不覆盖现有账号
2025-12-27 12:37:49 +08:00
empty
dab863fcfe feat: 添加多账号 OAuth 支持
- 新增 add-account.js OAuth 批量授权辅助工具
- 新增 account-manager.js 多账号管理模块(加权轮询、自动刷新、健康度统计)
- 新增 accounts.json.example 配置示例
- 修改 auth.js 支持多账号模式(优先检测 accounts.json)
2025-12-27 12:23:41 +08:00
empty
eb1096ce54 feat: 集成阿里云日志服务(SLS)
- 添加 aliyun-log SDK 依赖
- 新增 sls-logger.js 模块,支持批量日志上报、静默降级
- 在四个 API 处理函数中集成请求日志记录
- 更新 .env.example 添加 SLS 配置示例
2025-12-27 03:08:01 +08:00
Claude Code
dec2f26b5c fix: 修复 system 提示词中敏感词导致的 403 错误
- 修改 docker-compose.yml:将宿主机端口从 3000 改为 3001
- 修改 routes.js:增强 system 字段过滤逻辑,过滤所有项中的敏感词
- 修改 transformers/request-anthropic.js:添加 filterSensitiveKeywords 函数
- 修改 user-agent-updater.js:优化错误日志输出,增加超时时间

过滤规则:
- "Claude Code" → "AI Assistant"
- "Claude" → "AI"
- "Anthropic" → "Factory"

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 18:56:26 +00:00
Claude Code
0b04c300c0 fix: 修复 Claude Code 伪装为 Factory CLI 的 403 错误
主要修改:
1. 过滤 anthropic-beta header 中的 Claude Code 特有标识
2. 删除 context_management 字段
3. 过滤所有 Claude Code 特有工具(Skill, EnterPlanMode 等)
4. 过滤所有 MCP 相关工具
5. 过滤 messages 内容中的 Claude Code 特征文本
6. 处理 system 字段中的 cache_control 和字符串替换
7. 添加认证容错机制,token 失效时降级到 client authorization

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 15:46:09 +00:00
1eon
a8928bce32 update model 2025-12-24 03:50:26 +08:00
1eon
3626c6683a Merge branch 'main' of https://github.com/1e0n/droid2api 2025-12-24 03:48:17 +08:00
1eon
5e962cb00f feat: Remove gpt-5 redirect and Opus 4.1 model, and add GPT-5.2 and Gemini-3-Flash models. 2025-12-24 03:44:04 +08:00
1e0n
60754b65cf add gpt 5.2 2025-12-12 04:22:20 +08:00
1eon
aa3bb3c65b add gpt-5.1-codex-max support 2025-12-05 21:11:53 +08:00
1eon
93284c80ff update models 2025-11-25 12:24:33 +08:00
1eon
0f4f2e3509 add gemini 3.0 pro support 2025-11-19 01:29:45 +08:00
1eon
62a384f34b feat: add dynamic x-api-provider and reasoning_effort support
- Add per-model provider configuration in config.json
- Implement getModelProvider() to fetch provider from model config
- Update all header generators to accept dynamic provider parameter
- Add reasoning_effort field handling for common endpoint type
- Support auto/low/medium/high/off reasoning levels for common models

This enables flexible multi-provider support and reasoning control
across different endpoint types (anthropic, openai, common).
2025-11-19 01:25:01 +08:00
1eon
c31b680d95 feat: add dynamic user-agent version updater
- Add user-agent-updater.js to automatically fetch latest factory-cli version
- Fetch version from https://downloads.factory.ai/factory-cli/LATEST on startup
- Automatically refresh version every hour
- Implement retry mechanism: max 3 retries with 1-minute intervals on failure
- Use user_agent from config.json as fallback value
- Update config.js to use dynamic user-agent
- Initialize updater in server.js startup sequence
2025-11-16 16:25:15 +08:00
1e0n
3c0e922cbd add gpt-5.1 and gpt-5.1-codex support 2025-11-14 11:33:37 +08:00
30 changed files with 3342 additions and 226 deletions

View File

@@ -5,3 +5,41 @@ FACTORY_API_KEY=your_factory_api_key_here
# 方式2使用refresh token自动刷新次优先级
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_SECRET=your_access_key_secret
ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com
ALIYUN_SLS_PROJECT=your_project_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

4
.gitignore vendored
View File

@@ -3,4 +3,6 @@ node_modules/
.env
.DS_Store
*.txt
AGENTS.md
AGENTS.md
accounts.json
.serena/

View File

@@ -225,13 +225,27 @@ curl http://localhost:3000/v1/models
## 环境变量说明
### 认证配置
| 变量名 | 必需 | 优先级 | 说明 |
|--------|------|--------|------|
| `FACTORY_API_KEY` | 否 | 最高 | 固定API密钥跳过自动刷新推荐生产环境 |
| `DROID_REFRESH_KEY` | 否 | 次高 | Factory refresh token用于自动刷新 API key |
| `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 日志库名称 |
**注意**:阿里云日志服务用于记录请求日志,便于监控和排查问题。如不需要可不配置。
## 故障排查

View File

@@ -1,5 +1,5 @@
# 使用官方 Node.js 运行时作为基础镜像
FROM node:24-alpine
FROM node:20-alpine
# 设置工作目录
WORKDIR /app
@@ -8,7 +8,7 @@ WORKDIR /app
COPY package*.json ./
# 安装项目依赖
RUN npm ci --only=production
RUN npm ci --omit=dev
# 复制项目文件
COPY . .

131
README.md
View File

@@ -10,15 +10,15 @@ OpenAI 兼容的 API 代理服务器,统一访问不同的 LLM 模型。
- **FACTORY_API_KEY优先级** - 环境变量设置固定API密钥跳过自动刷新
- **令牌自动刷新** - WorkOS OAuth集成系统每6小时自动刷新access_token
- **客户端授权回退** - 无配置时使用客户端请求头的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模式** - 完全遵循客户端原始请求,不做任何推理参数修改
- **固定级别** - off/low/medium/high强制覆盖客户端推理设置
- **固定级别** - off/low/medium/high/xhigh强制覆盖客户端推理设置
- **OpenAI模型** - 自动注入reasoning字段effort参数控制推理强度
- **Anthropic模型** - 自动配置thinking字段和budget_tokens (4096/12288/24576)
- **Anthropic模型** - 自动配置thinking字段和budget_tokens (4096/12288/24576/40960)
- **智能头管理** - 根据推理级别自动添加/移除anthropic-beta相关标识
### 🚀 服务器部署/Docker部署
@@ -54,14 +54,15 @@ npm install
- `express` - Web服务器框架
- `node-fetch` - HTTP请求库
- `https-proxy-agent` - 为外部请求提供代理支持
- `aliyun-log` - 阿里云日志服务SDK可选用于请求日志记录
> 💡 **首次使用必须执行 `npm install`**,之后只需要 `npm start` 启动服务即可。
## 快速开始
### 1. 配置认证(种方式)
### 1. 配置认证(种方式)
**优先级FACTORY_API_KEY > refresh_token > 客户端authorization**
**优先级:accounts.json (多账号OAuth) > FACTORY_API_KEY > refresh_token > 客户端authorization**
```bash
# 方式1固定API密钥最高优先级
@@ -78,8 +79,49 @@ export DROID_REFRESH_KEY="your_refresh_token_here"
# 方式4无配置客户端授权
# 服务器将使用客户端请求头中的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. 配置模型(可选)
编辑 `config.json` 添加或修改模型:
@@ -87,24 +129,43 @@ export DROID_REFRESH_KEY="your_refresh_token_here"
```json
{
"port": 3000,
"model_redirects": {
"claude-3-5-haiku-20241022": "claude-haiku-4-5-20251001"
},
"models": [
{
"name": "Claude Opus 4",
"id": "claude-opus-4-1-20250805",
"name": "Opus 4.5",
"id": "claude-opus-4-5-20251101",
"type": "anthropic",
"reasoning": "high"
"reasoning": "auto",
"provider": "anthropic"
},
{
"name": "GPT-5",
"id": "gpt-5-2025-08-07",
"name": "GPT-5.2",
"id": "gpt-5.2",
"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. 配置网络代理(可选)
通过 `config.json``proxies` 数组为所有下游请求配置代理。数组为空表示直连;配置多个代理时会按照数组顺序轮询使用。
@@ -131,13 +192,14 @@ export DROID_REFRESH_KEY="your_refresh_token_here"
#### 推理级别配置
每个模型支持种推理级别:
每个模型支持种推理级别:
- **`auto`** - 遵循客户端原始请求,不做任何推理参数修改
- **`off`** - 强制关闭推理功能,删除所有推理字段
- **`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)
- **`xhigh`** - 超高级推理 (Anthropic: 40960 tokens, OpenAI: xhigh effort)
**对于Anthropic模型 (Claude)**
```json
@@ -223,6 +285,29 @@ Docker部署支持以下环境变量
- `PORT` - 服务端口默认3000
- `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使用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
export FACTORY_API_KEY="your_api_key"
```
使用固定API密钥停用自动刷新机制。
2. **refresh_token机制**
3. **refresh_token机制**
```bash
export DROID_REFRESH_KEY="your_refresh_token"
```
自动刷新令牌每6小时更新一次。
3. **客户端授权**fallback
4. **客户端授权**fallback
无需配置直接使用客户端请求头的authorization字段。
### 什么时候使用FACTORY_API_KEY
@@ -382,7 +470,7 @@ droid2api完全尊重客户端的stream参数设置
{
"id": "claude-opus-4-1-20250805",
"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) | 简单任务 |
| `medium` | 中度推理 (12288 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中的配置
3. 查看服务器日志确认推理字段是否正确处理

420
account-manager.js Normal file
View File

@@ -0,0 +1,420 @@
import fs from 'fs';
import path from 'path';
import { logInfo, logDebug, logError } from './logger.js';
import { getNextProxyAgent } from './proxy-manager.js';
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
/**
* Account Manager - 管理多个 OAuth 账号的选择、刷新和统计
*
* 设计思路:
* 1. 支持多个 refresh_token 账号池
* 2. 基于健康度加权轮询选择账号
* 3. 自动刷新 access_token
* 4. 401/402 时自动禁用异常账号
*/
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
const REFRESH_INTERVAL_HOURS = 6;
const CLIENT_ID = 'client_01HNM792M5G5G1A2THWPXKFMXB';
class AccountManager {
constructor() {
this.accounts = [];
this.refreshLocks = new Map(); // 刷新锁,避免同一账号并发刷新
this.settings = {
algorithm: 'weighted', // 'weighted' or 'simple'
refresh_interval_hours: REFRESH_INTERVAL_HOURS,
disable_on_401: true,
disable_on_402: true
};
this.simpleIndex = 0;
this.configPath = null;
this.endpointStats = {}; // 端点统计
}
/**
* 从配置文件加载账号
*/
async loadAccounts(configPath = null) {
// 优先检查环境变量中的多账号配置
const envAccounts = process.env.OAUTH_ACCOUNTS;
if (envAccounts) {
try {
const parsed = JSON.parse(envAccounts);
this.accounts = this._normalizeAccounts(parsed);
logInfo(`AccountManager: 从环境变量加载了 ${this.accounts.length} 个账号`);
return true;
} catch (e) {
logError('AccountManager: 解析 OAUTH_ACCOUNTS 环境变量失败', e);
}
}
// 检查配置文件
const possiblePaths = [
configPath,
path.join(process.cwd(), 'accounts.json'),
path.join(process.cwd(), 'oauth_accounts.json')
].filter(Boolean);
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
try {
const content = fs.readFileSync(p, 'utf-8');
const data = JSON.parse(content);
this.accounts = this._normalizeAccounts(data.accounts || data);
if (data.settings) {
this.settings = { ...this.settings, ...data.settings };
}
this.configPath = p;
logInfo(`AccountManager: 从 ${p} 加载了 ${this.accounts.length} 个账号`);
return true;
} catch (e) {
logError(`AccountManager: 读取配置文件 ${p} 失败`, e);
}
}
}
logInfo('AccountManager: 未找到多账号配置,将回退到单账号模式');
return false;
}
/**
* 标准化账号数据结构
*/
_normalizeAccounts(accounts) {
if (!Array.isArray(accounts)) {
accounts = [accounts];
}
return accounts.map((acc, index) => ({
id: acc.id || `account_${index + 1}`,
name: acc.name || `账号${index + 1}`,
refresh_token: acc.refresh_token,
access_token: acc.access_token || null,
last_refresh: acc.last_refresh ? new Date(acc.last_refresh).getTime() : null,
status: acc.status || 'active', // 'active', 'disabled', 'rate_limited'
disable_reason: acc.disable_reason || null,
stats: acc.stats || { success: 0, fail: 0 }
}));
}
/**
* 获取活跃账号列表
*/
getActiveAccounts() {
return this.accounts.filter(acc => acc.status === 'active');
}
/**
* 选择一个账号(基于配置的算法)
*/
selectAccount() {
const activeAccounts = this.getActiveAccounts();
if (activeAccounts.length === 0) {
throw new Error('AccountManager: 没有可用账号 - 所有账号已被禁用');
}
if (activeAccounts.length === 1) {
return activeAccounts[0];
}
if (this.settings.algorithm === 'simple') {
return this._simpleSelect(activeAccounts);
} else {
return this._weightedSelect(activeAccounts);
}
}
/**
* 简单轮询算法
*/
_simpleSelect(accounts) {
const account = accounts[this.simpleIndex % accounts.length];
this.simpleIndex = (this.simpleIndex + 1) % accounts.length;
logDebug(`AccountManager: 简单轮询选择账号 ${account.id}`);
return account;
}
/**
* 基于健康度的加权选择算法
*/
_weightedSelect(accounts) {
const weights = accounts.map(acc => {
const total = acc.stats.success + acc.stats.fail;
if (total === 0) {
return 1; // 新账号默认权重
}
// 成功率 + 0.1(确保失败账号也有机会恢复)
return (acc.stats.success / total) + 0.1;
});
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
let random = Math.random() * totalWeight;
for (let i = 0; i < weights.length; i++) {
random -= weights[i];
if (random <= 0) {
const acc = accounts[i];
logDebug(`AccountManager: 加权选择账号 ${acc.id}, 健康度: ${((weights[i] - 0.1) * 100).toFixed(1)}%`);
return acc;
}
}
return accounts[accounts.length - 1];
}
/**
* 获取账号的 access_token自动刷新
*/
async getAccessToken(account) {
// 检查是否需要刷新
const needsRefresh = !account.access_token || this._shouldRefresh(account);
if (needsRefresh) {
await this._refreshTokenWithLock(account);
}
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
*/
_shouldRefresh(account) {
if (!account.last_refresh) {
return true;
}
const hoursSinceRefresh = (Date.now() - account.last_refresh) / (1000 * 60 * 60);
return hoursSinceRefresh >= this.settings.refresh_interval_hours;
}
/**
* 刷新账号的 access_token
*/
async _refreshToken(account) {
logInfo(`AccountManager: 刷新账号 ${account.id} 的 token...`);
try {
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
const refreshConfig = getRefreshConfig();
const data = await requestRefreshToken({
refreshUrl: REFRESH_URL,
refreshToken: account.refresh_token,
clientId: CLIENT_ID,
proxyAgentInfo,
...refreshConfig
});
// 更新账号信息
account.access_token = data.access_token;
account.refresh_token = data.refresh_token; // 更新 refresh_token
account.last_refresh = Date.now();
// 记录用户信息
if (data.user) {
account.email = data.user.email;
logInfo(`AccountManager: 账号 ${account.id} 刷新成功 - ${data.user.email}`);
}
// 保存配置
this._saveConfig();
return data.access_token;
} catch (error) {
logError(`AccountManager: 账号 ${account.id} 刷新失败`, error);
// 刷新失败可能是 refresh_token 失效,禁用账号
if (error.message.includes('401') || error.message.includes('invalid')) {
this.disableAccount(account.id, 'refresh_token_invalid');
}
throw error;
}
}
/**
* 记录请求结果
*/
recordResult(accountId, endpoint, success, statusCode) {
const account = this.accounts.find(acc => acc.id === accountId);
if (!account) return;
if (success) {
account.stats.success++;
} else {
account.stats.fail++;
// 检查是否需要禁用账号
if (statusCode === 401 && this.settings.disable_on_401) {
this.disableAccount(accountId, '401_unauthorized');
} else if (statusCode === 402 && this.settings.disable_on_402) {
this.disableAccount(accountId, '402_payment_required');
}
}
// 更新端点统计
if (!this.endpointStats[endpoint]) {
this.endpointStats[endpoint] = { success: 0, fail: 0 };
}
if (success) {
this.endpointStats[endpoint].success++;
} else {
this.endpointStats[endpoint].fail++;
}
}
/**
* 禁用账号
*/
disableAccount(accountId, reason) {
const account = this.accounts.find(acc => acc.id === accountId);
if (!account) return;
account.status = 'disabled';
account.disable_reason = reason;
account.disabled_at = new Date().toISOString();
logError(`AccountManager: 账号 ${accountId} 已禁用,原因: ${reason}`);
// 保存配置
this._saveConfig();
// 记录到废弃账号文件
this._logDeprecatedAccount(account);
}
/**
* 记录废弃账号到文件
*/
_logDeprecatedAccount(account) {
try {
const logPath = path.join(process.cwd(), 'deprecated_accounts.txt');
const logEntry = `[${new Date().toISOString()}] ${account.id} (${account.email || 'unknown'}) - ${account.disable_reason}\n`;
fs.appendFileSync(logPath, logEntry, 'utf-8');
} catch (e) {
logError('AccountManager: 记录废弃账号失败', e);
}
}
/**
* 保存配置到文件
*/
_saveConfig() {
if (!this.configPath) return;
try {
const data = {
accounts: this.accounts.map(acc => ({
id: acc.id,
name: acc.name,
refresh_token: acc.refresh_token,
access_token: acc.access_token,
last_refresh: acc.last_refresh ? new Date(acc.last_refresh).toISOString() : null,
status: acc.status,
disable_reason: acc.disable_reason,
email: acc.email,
stats: acc.stats
})),
settings: this.settings,
last_updated: new Date().toISOString()
};
fs.writeFileSync(this.configPath, JSON.stringify(data, null, 2), 'utf-8');
logDebug(`AccountManager: 配置已保存到 ${this.configPath}`);
} catch (e) {
logError('AccountManager: 保存配置失败', e);
}
}
/**
* 获取状态信息(用于 /status 页面)
*/
getStatus() {
return {
total_accounts: this.accounts.length,
active_accounts: this.getActiveAccounts().length,
algorithm: this.settings.algorithm,
accounts: this.accounts.map(acc => ({
id: acc.id,
name: acc.name,
email: acc.email || 'unknown',
status: acc.status,
disable_reason: acc.disable_reason,
stats: acc.stats,
health: this._calculateHealth(acc),
last_refresh: acc.last_refresh ? new Date(acc.last_refresh).toISOString() : null
})),
endpoint_stats: this.endpointStats
};
}
/**
* 计算账号健康度
*/
_calculateHealth(account) {
const total = account.stats.success + account.stats.fail;
if (total === 0) return 100;
return Math.round((account.stats.success / total) * 100);
}
/**
* 检查是否有多账号配置
*/
hasMultipleAccounts() {
return this.accounts.length > 1;
}
/**
* 获取账号数量
*/
getAccountCount() {
return this.accounts.length;
}
}
// 单例实例
let accountManagerInstance = null;
/**
* 获取 AccountManager 实例
*/
export function getAccountManager() {
if (!accountManagerInstance) {
accountManagerInstance = new AccountManager();
}
return accountManagerInstance;
}
/**
* 初始化 AccountManager
*/
export async function initializeAccountManager() {
const manager = getAccountManager();
const loaded = await manager.loadAccounts();
return loaded;
}
export default AccountManager;

22
accounts.json.example Normal file
View File

@@ -0,0 +1,22 @@
{
"accounts": [
{
"id": "account_1",
"name": "主账号",
"refresh_token": "YOUR_REFRESH_TOKEN_1",
"status": "active"
},
{
"id": "account_2",
"name": "备用账号",
"refresh_token": "YOUR_REFRESH_TOKEN_2",
"status": "active"
}
],
"settings": {
"algorithm": "weighted",
"refresh_interval_hours": 6,
"disable_on_401": true,
"disable_on_402": true
}
}

356
add-account.js Normal file
View File

@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* add-account.js - OAuth 账号授权辅助工具
*
* 用途:简化多账号 OAuth refresh_token 的获取流程
*
* 使用方式:
* node add-account.js # 交互式添加账号
* node add-account.js --loop # 连续添加多个账号
*
* 流程:
* 1. 向 WorkOS 请求设备授权码
* 2. 自动打开浏览器(或显示链接)
* 3. 用户在浏览器完成登录
* 4. 脚本轮询获取 access_token + refresh_token
* 5. 自动保存到 accounts.json
*/
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// WorkOS 配置(从 Factory CLI 逆向获取)
const WORKOS_CLIENT_ID = 'client_01HNM792M5G5G1A2THWPXKFMXB';
const WORKOS_AUTHORIZE_URL = 'https://api.workos.com/user_management/authorize/device';
const WORKOS_TOKEN_URL = 'https://api.workos.com/user_management/authenticate';
// 配置
const ACCOUNTS_FILE = path.join(process.cwd(), 'accounts.json');
const POLL_INTERVAL_MS = 2000; // 轮询间隔 2 秒
const POLL_TIMEOUT_MS = 300000; // 轮询超时 5 分钟
/**
* 请求设备授权码
*/
async function requestDeviceCode() {
console.log('\n🔐 正在请求设备授权码...');
const response = await fetch(WORKOS_AUTHORIZE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: WORKOS_CLIENT_ID
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`请求设备授权码失败: ${response.status} ${error}`);
}
const data = await response.json();
return data;
}
/**
* 轮询获取 token
*/
async function pollForToken(deviceCode) {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
try {
const formData = new URLSearchParams();
formData.append('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
formData.append('device_code', deviceCode);
formData.append('client_id', WORKOS_CLIENT_ID);
const response = await fetch(WORKOS_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
});
if (response.ok) {
const data = await response.json();
return data;
}
const errorData = await response.json().catch(() => ({}));
if (errorData.error === 'authorization_pending') {
// 用户还未授权,继续轮询
process.stdout.write('.');
} else if (errorData.error === 'slow_down') {
// 需要降低轮询速度
await sleep(POLL_INTERVAL_MS * 2);
continue;
} else if (errorData.error === 'expired_token') {
throw new Error('授权码已过期,请重新开始');
} else if (errorData.error === 'access_denied') {
throw new Error('授权被拒绝');
}
} catch (err) {
if (err.message.includes('授权')) {
throw err;
}
// 网络错误,继续轮询
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error('授权超时5分钟请重新开始');
}
/**
* 保存账号到配置文件
*/
function saveAccount(tokenData) {
let accounts = { accounts: [], settings: {} };
// 读取现有配置
if (fs.existsSync(ACCOUNTS_FILE)) {
try {
accounts = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, 'utf-8'));
} catch (e) {
console.warn('⚠️ 无法读取现有配置,将创建新文件');
}
}
// 确保 accounts 数组存在
if (!accounts.accounts) {
accounts.accounts = [];
}
// 提取用户信息
const user = tokenData.user || {};
const email = user.email || 'unknown';
// 检查是否已存在此账号
const existingIndex = accounts.accounts.findIndex(acc => acc.email === email);
if (existingIndex >= 0) {
// 更新现有账号
accounts.accounts[existingIndex] = {
...accounts.accounts[existingIndex],
refresh_token: tokenData.refresh_token,
access_token: tokenData.access_token,
last_refresh: new Date().toISOString(),
status: 'active'
};
console.log(`\n✅ 账号已更新: ${email}`);
} else {
// 添加新账号
const newAccount = {
id: `account_${accounts.accounts.length + 1}`,
name: user.first_name ? `${user.first_name} ${user.last_name}` : email,
email: email,
refresh_token: tokenData.refresh_token,
access_token: tokenData.access_token,
last_refresh: new Date().toISOString(),
status: 'active',
stats: { success: 0, fail: 0 }
};
accounts.accounts.push(newAccount);
console.log(`\n✅ 新账号已添加: ${email}`);
}
// 设置默认配置
if (!accounts.settings || Object.keys(accounts.settings).length === 0) {
accounts.settings = {
algorithm: 'weighted',
refresh_interval_hours: 6,
disable_on_401: true,
disable_on_402: true
};
}
// 保存配置
accounts.last_updated = new Date().toISOString();
fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), 'utf-8');
console.log(`📁 配置已保存到: ${ACCOUNTS_FILE}`);
return accounts;
}
/**
* 尝试自动打开浏览器
*/
async function openBrowser(url) {
const { exec } = await import('child_process');
const platform = process.platform;
return new Promise((resolve) => {
let command;
if (platform === 'darwin') {
command = `open "${url}"`;
} else if (platform === 'win32') {
command = `start "" "${url}"`;
} else {
command = `xdg-open "${url}"`;
}
exec(command, (error) => {
if (error) {
console.log('\n⚠ 无法自动打开浏览器,请手动打开以下链接');
resolve(false);
} else {
resolve(true);
}
});
});
}
/**
* 睡眠函数
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 显示当前账号状态
*/
function showAccountStatus() {
if (!fs.existsSync(ACCOUNTS_FILE)) {
console.log('\n📋 当前没有已配置的账号');
return;
}
try {
const accounts = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, 'utf-8'));
console.log('\n📋 当前已配置的账号:');
console.log('─'.repeat(50));
if (!accounts.accounts || accounts.accounts.length === 0) {
console.log(' (无)');
} else {
accounts.accounts.forEach((acc, i) => {
const status = acc.status === 'active' ? '🟢' : '🔴';
console.log(` ${i + 1}. ${status} ${acc.email || acc.name} [${acc.id}]`);
});
}
console.log('─'.repeat(50));
} catch (e) {
console.log('\n⚠ 无法读取账号配置');
}
}
/**
* 读取用户输入
*/
function readline(question) {
return new Promise((resolve) => {
const rl = require('readline').createInterface({
input: process.stdin,
output: process.stdout
});
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/**
* 主流程
*/
async function addAccount() {
console.log('═'.repeat(50));
console.log(' 🔐 OAuth 账号授权工具');
console.log('═'.repeat(50));
try {
// 1. 请求设备授权码
const deviceAuth = await requestDeviceCode();
console.log('\n📱 请在浏览器中完成授权:');
console.log('─'.repeat(50));
console.log(` 验证码: ${deviceAuth.user_code}`);
console.log(` 链接: ${deviceAuth.verification_uri_complete || deviceAuth.verification_uri}`);
console.log('─'.repeat(50));
// 2. 尝试自动打开浏览器
const browserOpened = await openBrowser(
deviceAuth.verification_uri_complete || deviceAuth.verification_uri
);
if (browserOpened) {
console.log('\n🌐 已自动打开浏览器,请完成登录...');
} else {
console.log('\n👆 请复制上面的链接到浏览器打开');
}
// 3. 轮询获取 token
console.log('\n⏳ 等待授权中');
const tokenData = await pollForToken(deviceAuth.device_code);
// 4. 保存账号
const accounts = saveAccount(tokenData);
console.log(`\n🎉 授权成功!当前共有 ${accounts.accounts.length} 个账号`);
return true;
} catch (error) {
console.error(`\n❌ 错误: ${error.message}`);
return false;
}
}
/**
* 入口函数
*/
async function main() {
const args = process.argv.slice(2);
const loopMode = args.includes('--loop') || args.includes('-l');
// 显示当前状态
showAccountStatus();
do {
const success = await addAccount();
if (loopMode && success) {
console.log('\n━'.repeat(50));
console.log('按 Enter 继续添加下一个账号,或输入 q 退出');
// 等待用户输入
const readlineModule = await import('readline');
const rl = readlineModule.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise(resolve => {
rl.question('> ', (ans) => {
rl.close();
resolve(ans);
});
});
if (answer.toLowerCase() === 'q') {
break;
}
} else if (!loopMode) {
break;
}
} while (loopMode);
// 显示最终状态
showAccountStatus();
console.log('\n✨ 完成!');
}
// 运行
main().catch(console.error);

146
auth-middleware.js Normal file
View 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;

292
auth.js
View File

@@ -1,22 +1,28 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import fetch from 'node-fetch';
import { logDebug, logError, logInfo } from './logger.js';
import { getNextProxyAgent } from './proxy-manager.js';
import { getAccountManager, initializeAccountManager } from './account-manager.js';
import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
// State management for API key and refresh
let currentApiKey = null;
let currentRefreshToken = null;
let lastRefreshTime = null;
let tokenExpiresAt = null; // Token 过期时间戳 (ms)
let clientId = null;
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client'
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account'
let authFilePath = null;
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
let multiAccountMode = false; // 是否启用多账号模式
let lastSelectedAccountId = null; // 记录最后选择的账号ID用于结果回调
let refreshInFlight = null; // 刷新锁,避免并发刷新
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 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)
@@ -27,10 +33,10 @@ const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours
function generateULID() {
// Crockford's Base32 alphabet (no I, L, O, U to avoid confusion)
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
// Get timestamp in milliseconds
const timestamp = Date.now();
// Encode timestamp to 10 characters
let time = '';
let ts = timestamp;
@@ -39,14 +45,14 @@ function generateULID() {
time = ENCODING[mod] + time;
ts = Math.floor(ts / 32);
}
// Generate 16 random characters
let randomPart = '';
for (let i = 0; i < 16; i++) {
const rand = Math.floor(Math.random() * 32);
randomPart += ENCODING[rand];
}
return time + randomPart;
}
@@ -60,10 +66,34 @@ function generateClientId() {
/**
* Load auth configuration with priority system
* Priority: FACTORY_API_KEY > refresh token mechanism > client authorization
* Priority: accounts.json (multi-account) > FACTORY_API_KEY > refresh token mechanism > client authorization
*/
function loadAuthConfig() {
// 1. Check FACTORY_API_KEY environment variable (highest priority)
// 0. Check accounts.json for multi-account mode (highest priority)
const accountsPath = path.join(process.cwd(), 'accounts.json');
logDebug(`Checking accounts.json at: ${accountsPath}`);
try {
if (fs.existsSync(accountsPath)) {
logDebug('accounts.json exists, reading...');
const accountsContent = fs.readFileSync(accountsPath, 'utf-8');
const accountsData = JSON.parse(accountsContent);
logDebug(`accounts.json parsed, accounts: ${accountsData.accounts?.length || 0}`);
if (accountsData.accounts && accountsData.accounts.length > 0) {
const activeAccounts = accountsData.accounts.filter(acc => acc.status === 'active');
if (activeAccounts.length > 0) {
logInfo(`Found accounts.json with ${activeAccounts.length} active account(s), enabling multi-account mode`);
multiAccountMode = true;
authSource = 'multi_account';
return { type: 'multi_account', value: accountsPath };
}
}
}
} catch (error) {
logError('Error reading accounts.json', error);
}
// 1. Check FACTORY_API_KEY environment variable
const factoryKey = process.env.FACTORY_API_KEY;
if (factoryKey && factoryKey.trim() !== '') {
logInfo('Using fixed API key from FACTORY_API_KEY environment variable');
@@ -84,22 +114,38 @@ function loadAuthConfig() {
// 3. Check ~/.factory/auth.json
const homeDir = os.homedir();
const factoryAuthPath = path.join(homeDir, '.factory', 'auth.json');
try {
if (fs.existsSync(factoryAuthPath)) {
const authContent = fs.readFileSync(factoryAuthPath, 'utf-8');
const authData = JSON.parse(authContent);
if (authData.refresh_token && authData.refresh_token.trim() !== '') {
logInfo('Using refresh token from ~/.factory/auth.json');
authSource = 'file';
authFilePath = factoryAuthPath;
// Also load access_token if available
// Also load access_token if available and not expired
if (authData.access_token) {
currentApiKey = authData.access_token.trim();
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();
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() };
}
}
@@ -121,76 +167,80 @@ async function refreshApiKey() {
throw new Error('No refresh token available');
}
if (!clientId) {
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
logDebug(`Using fixed client ID: ${clientId}`);
if (refreshInFlight) {
return refreshInFlight;
}
logInfo('Refreshing API key...');
refreshInFlight = (async () => {
if (!clientId) {
clientId = 'client_01HNM792M5G5G1A2THWPXKFMXB';
logDebug(`Using fixed client ID: ${clientId}`);
}
logInfo('Refreshing API key...');
try {
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
const refreshConfig = getRefreshConfig();
const data = await requestRefreshToken({
refreshUrl: REFRESH_URL,
refreshToken: currentRefreshToken,
clientId,
proxyAgentInfo,
...refreshConfig
});
// Update tokens
currentApiKey = data.access_token;
currentRefreshToken = data.refresh_token;
lastRefreshTime = Date.now();
// 设置过期时间默认8小时
tokenExpiresAt = lastRefreshTime + TOKEN_VALID_HOURS * 60 * 60 * 1000;
// Log user info
if (data.user) {
logInfo(`Authenticated as: ${data.user.email} (${data.user.first_name} ${data.user.last_name})`);
logInfo(`User ID: ${data.user.id}`);
logInfo(`Organization ID: ${data.organization_id}`);
}
// Save tokens to file
saveTokens(data.access_token, data.refresh_token);
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
logInfo(`Token expires at: ${new Date(tokenExpiresAt).toISOString()}`);
logInfo('API key refreshed successfully');
return data.access_token;
} catch (error) {
logError('Failed to refresh API key', error);
throw error;
}
})();
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 fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
};
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
currentApiKey = data.access_token;
currentRefreshToken = data.refresh_token;
lastRefreshTime = Date.now();
// Log user info
if (data.user) {
logInfo(`Authenticated as: ${data.user.email} (${data.user.first_name} ${data.user.last_name})`);
logInfo(`User ID: ${data.user.id}`);
logInfo(`Organization ID: ${data.organization_id}`);
}
// Save tokens to file
saveTokens(data.access_token, data.refresh_token);
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
logInfo('API key refreshed successfully');
return data.access_token;
} catch (error) {
logError('Failed to refresh API key', error);
throw error;
return await refreshInFlight;
} finally {
refreshInFlight = null;
}
}
/**
* 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 {
const now = Date.now();
const expiresAt = new Date(now + expiresInMs).toISOString();
const authData = {
access_token: accessToken,
refresh_token: refreshToken,
last_updated: new Date().toISOString()
expires_at: expiresAt,
last_updated: new Date(now).toISOString()
};
// Ensure directory exists
@@ -206,6 +256,7 @@ function saveTokens(accessToken, refreshToken) {
Object.assign(authData, existingData, {
access_token: accessToken,
refresh_token: refreshToken,
expires_at: expiresAt,
last_updated: authData.last_updated
});
} catch (error) {
@@ -222,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() {
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) {
return true;
}
const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60);
const hoursSinceRefresh = (now - lastRefreshTime) / (1000 * 60 * 60);
return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
}
@@ -239,26 +303,45 @@ function shouldRefresh() {
export async function initializeAuth() {
try {
const authConfig = loadAuthConfig();
if (authConfig.type === 'factory_key') {
if (authConfig.type === 'multi_account') {
// Multi-account mode - initialize AccountManager
const loaded = await initializeAccountManager();
if (loaded) {
const manager = getAccountManager();
logInfo(`Auth system initialized with multi-account mode (${manager.getAccountCount()} accounts)`);
} else {
logError('Failed to initialize AccountManager, falling back to client authorization');
authSource = 'client';
multiAccountMode = false;
}
} else if (authConfig.type === 'factory_key') {
// Using fixed FACTORY_API_KEY, no refresh needed
logInfo('Auth system initialized with fixed API key');
} else if (authConfig.type === 'refresh') {
// Using refresh token mechanism
currentRefreshToken = authConfig.value;
// Always refresh on startup to get fresh token
await refreshApiKey();
logInfo('Auth system initialized with refresh token mechanism');
// Try to refresh on startup to get fresh token
try {
await refreshApiKey();
logInfo('Auth system initialized with refresh token mechanism');
} catch (refreshError) {
logError('Failed to refresh token on startup, falling back to client authorization', refreshError);
authSource = 'client';
logInfo('Auth system fallback to client authorization mode');
}
} else {
// Using client authorization, no setup needed
logInfo('Auth system initialized for client authorization mode');
}
logInfo('Auth system initialized successfully');
} catch (error) {
logError('Failed to initialize auth system', error);
throw error;
// Don't throw error, allow server to start with client authorization
authSource = 'client';
logInfo('Auth system fallback to client authorization mode');
}
}
@@ -267,11 +350,21 @@ export async function initializeAuth() {
* @param {string} clientAuthorization - Authorization header from client request (optional)
*/
export async function getApiKey(clientAuthorization = null) {
// Priority 0: Multi-account mode
if (authSource === 'multi_account' && multiAccountMode) {
const manager = getAccountManager();
const account = manager.selectAccount();
lastSelectedAccountId = account.id;
const accessToken = await manager.getAccessToken(account);
return `Bearer ${accessToken}`;
}
// Priority 1: FACTORY_API_KEY environment variable
if (authSource === 'factory_key' && factoryApiKey) {
return `Bearer ${factoryApiKey}`;
}
// Priority 2: Refresh token mechanism
if (authSource === 'env' || authSource === 'file') {
// Check if we need to refresh
@@ -286,13 +379,44 @@ export async function getApiKey(clientAuthorization = null) {
return `Bearer ${currentApiKey}`;
}
// Priority 3: Client authorization header
if (clientAuthorization) {
logDebug('Using client authorization header');
return clientAuthorization;
}
// No authorization available
throw new Error('No authorization available. Please configure FACTORY_API_KEY, refresh token, or provide client authorization.');
throw new Error('No authorization available. Please configure accounts.json, FACTORY_API_KEY, refresh token, or provide client authorization.');
}
/**
* Record authentication result for multi-account mode
* @param {string} endpoint - The API endpoint called
* @param {boolean} success - Whether the request was successful
* @param {number} statusCode - HTTP status code
*/
export function recordAuthResult(endpoint, success, statusCode) {
if (authSource === 'multi_account' && multiAccountMode && lastSelectedAccountId) {
const manager = getAccountManager();
manager.recordResult(lastSelectedAccountId, endpoint, success, statusCode);
}
}
/**
* Get auth status for /status endpoint
*/
export function getAuthStatus() {
if (authSource === 'multi_account' && multiAccountMode) {
const manager = getAccountManager();
return {
mode: 'multi_account',
...manager.getStatus()
};
}
return {
mode: authSource,
multiAccountMode: false
};
}

View File

@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { logInfo } from './logger.js';
import { getCurrentUserAgent } from './user-agent-updater.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -57,15 +58,19 @@ export function getModelReasoning(modelId) {
return null;
}
const reasoningLevel = model.reasoning.toLowerCase();
if (['low', 'medium', 'high', 'auto'].includes(reasoningLevel)) {
if (['low', 'medium', 'high', 'xhigh', 'auto'].includes(reasoningLevel)) {
return reasoningLevel;
}
return null;
}
export function getModelProvider(modelId) {
const model = getModelById(modelId);
return model?.provider || null;
}
export function getUserAgent() {
const cfg = getConfig();
return cfg.user_agent || 'factory-cli/0.19.3';
return getCurrentUserAgent();
}
export function getProxyConfigs() {
@@ -86,3 +91,43 @@ export function getRedirectedModelId(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']
};
}

View File

@@ -2,8 +2,7 @@
"port": 3000,
"model_redirects": {
"claude-3-5-haiku-20241022": "claude-haiku-4-5-20251001",
"claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
"gpt-5":"gpt-5-2025-08-07"
"claude-sonnet-4-5": "claude-sonnet-4-5-20250929"
},
"endpoint": [
{
@@ -22,42 +21,90 @@
"proxies": [],
"models": [
{
"name": "Opus 4.1",
"id": "claude-opus-4-1-20250805",
"name": "Opus 4.5",
"id": "claude-opus-4-5-20251101",
"type": "anthropic",
"reasoning": "auto"
"reasoning": "auto",
"provider": "anthropic"
},
{
"name": "Haiku 4.5",
"id": "claude-haiku-4-5-20251001",
"type": "anthropic",
"reasoning": "auto"
"reasoning": "auto",
"provider": "anthropic"
},
{
"name": "Sonnet 4.5",
"id": "claude-sonnet-4-5-20250929",
"type": "anthropic",
"reasoning": "auto"
"reasoning": "auto",
"provider": "anthropic"
},
{
"name": "GPT-5",
"id": "gpt-5-2025-08-07",
"name": "GPT-5.2",
"id": "gpt-5.2",
"type": "openai",
"reasoning": "auto"
"reasoning": "auto",
"provider": "openai"
},
{
"name": "GPT-5-Codex",
"id": "gpt-5-codex",
"name": "GPT-5.1",
"id": "gpt-5.1",
"type": "openai",
"reasoning": "off"
"reasoning": "auto",
"provider": "openai"
},
{
"name": "GPT-5.1 Codex",
"id": "gpt-5.1-codex",
"type": "openai",
"reasoning": "off",
"provider": "openai"
},
{
"name": "GPT-5.1 Codex Max",
"id": "gpt-5.1-codex-max",
"type": "openai",
"reasoning": "auto",
"provider": "openai"
},
{
"name": "GLM-4.6",
"id": "glm-4.6",
"type": "common"
"type": "common",
"reasoning": "off",
"provider": "fireworks"
},
{
"name": "Gemini-3-Pro",
"id": "gemini-3-pro-preview",
"type": "common",
"reasoning": "auto",
"provider": "google"
},
{
"name": "Gemini-3-Flash",
"id": "gemini-3-flash-preview",
"type": "common",
"reasoning": "auto",
"provider": "google"
}
],
"cors": {
"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.25.1",
"user_agent": "factory-cli/0.40.2",
"system_prompt": "You are Droid, an AI software engineering agent built by Factory.\n\n"
}
}

61
docker-compose.prod.yml Normal file
View 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

View File

@@ -1,11 +1,9 @@
version: '3.8'
services:
droid2api:
build: .
container_name: droid2api
ports:
- "3000:3000"
- "3001:3000"
environment:
# 认证配置(按优先级选择其一):
# 最高优先级固定API密钥推荐用于生产环境
@@ -13,9 +11,20 @@ services:
# 次优先级refresh token自动刷新机制
- DROID_REFRESH_KEY=${DROID_REFRESH_KEY}
# 可选如果需要修改端口在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:
# 可选持久化auth.json以保存刷新的tokens
- ./data:/app/data
# 多账号配置文件(由 sync-accounts.sh 同步)
- ./accounts.json:/app/accounts.json:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
@@ -23,3 +32,28 @@ services:
timeout: 10s
retries: 3
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
View 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
View 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);
}

View File

@@ -1,28 +1,29 @@
import { isDevMode } from './config.js';
import { sanitizeForLog, sanitizeLogMessage } from './log-sanitizer.js';
export function logInfo(message, data = null) {
console.log(`[INFO] ${message}`);
console.log(`[INFO] ${sanitizeLogMessage(message)}`);
if (data && isDevMode()) {
console.log(JSON.stringify(data, null, 2));
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
}
}
export function logDebug(message, data = null) {
if (isDevMode()) {
console.log(`[DEBUG] ${message}`);
console.log(`[DEBUG] ${sanitizeLogMessage(message)}`);
if (data) {
console.log(JSON.stringify(data, null, 2));
console.log(JSON.stringify(sanitizeForLog(data), null, 2));
}
}
}
export function logError(message, error = null) {
console.error(`[ERROR] ${message}`);
console.error(`[ERROR] ${sanitizeLogMessage(message)}`);
if (error) {
if (isDevMode()) {
console.error(error);
console.error(sanitizeForLog(error));
} 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) {
if (isDevMode()) {
console.log(`\n${'='.repeat(80)}`);
console.log(`[REQUEST] ${method} ${url}`);
console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`);
if (headers) {
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
}
if (body) {
console.log('[BODY]', JSON.stringify(body, null, 2));
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
}
console.log('='.repeat(80) + '\n');
} 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(`[RESPONSE] Status: ${status}`);
if (headers) {
console.log('[HEADERS]', JSON.stringify(headers, null, 2));
console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2));
}
if (body) {
console.log('[BODY]', JSON.stringify(body, null, 2));
console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2));
}
console.log('-'.repeat(80) + '\n');
} else {

173
package-lock.json generated
View File

@@ -1,19 +1,99 @@
{
"name": "droid2api",
"version": "1.3.5",
"version": "1.3.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "droid2api",
"version": "1.3.5",
"version": "1.3.7",
"license": "MIT",
"dependencies": {
"aliyun-log": "github:aliyun/aliyun-log-nodejs-sdk",
"express": "^4.18.2",
"https-proxy-agent": "^7.0.2",
"node-fetch": "^3.3.2"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
@@ -36,6 +116,18 @@
"node": ">= 14"
}
},
"node_modules/aliyun-log": {
"name": "@alicloud/log",
"version": "1.2.6",
"resolved": "git+ssh://git@github.com/aliyun/aliyun-log-nodejs-sdk.git#f5c2ab9cf5e0c7d3edd2fa1a15fc7f0a9946cd05",
"license": "MIT",
"dependencies": {
"debug": "^2.6.8",
"httpx": "^2.1.2",
"kitx": "^1.2.1",
"protobufjs": "^6.8.8"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -502,6 +594,39 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/httpx": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz",
"integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
"license": "MIT",
"dependencies": {
"@types/node": "^20",
"debug": "^4.1.1"
}
},
"node_modules/httpx/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/httpx/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -529,6 +654,18 @@
"node": ">= 0.10"
}
},
"node_modules/kitx": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/kitx/-/kitx-1.3.0.tgz",
"integrity": "sha512-fhBqFlXd0GkKTB+8ayLfpzPUw+LHxZlPAukPNBD1Om7JMeInT+/PxCAf1yLagvD+VKoyWhXtJR68xQkX/a0wOQ==",
"license": "MIT"
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -690,6 +827,32 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "6.11.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz",
"integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/long": "^4.0.1",
"@types/node": ">=13.7.0",
"long": "^4.0.0"
},
"bin": {
"pbjs": "bin/pbjs",
"pbts": "bin/pbts"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -931,6 +1094,12 @@
"node": ">= 0.6"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -1,19 +1,25 @@
{
"name": "droid2api",
"version": "1.3.6",
"version": "1.3.7",
"description": "OpenAI Compatible API Proxy",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
"dev": "node server.js",
"test": "node --test"
},
"keywords": ["openai", "api", "proxy"],
"keywords": [
"openai",
"api",
"proxy"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"https-proxy-agent": "^7.0.2",
"node-fetch": "^3.3.2"
"node-fetch": "^3.3.2",
"aliyun-log": "github:aliyun/aliyun-log-nodejs-sdk"
}
}

132
refresh-client.js Normal file
View 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;
}
}
}

389
routes.js
View File

@@ -1,6 +1,6 @@
import express from 'express';
import fetch from 'node-fetch';
import { getConfig, getModelById, getEndpointByType, getSystemPrompt, getModelReasoning, getRedirectedModelId } from './config.js';
import { getConfig, getModelById, getEndpointByType, getSystemPrompt, getModelReasoning, getRedirectedModelId, getModelProvider } from './config.js';
import { logInfo, logDebug, logError, logRequest, logResponse } from './logger.js';
import { transformToAnthropic, getAnthropicHeaders } from './transformers/request-anthropic.js';
import { transformToOpenAI, getOpenAIHeaders } from './transformers/request-openai.js';
@@ -9,9 +9,81 @@ import { AnthropicResponseTransformer } from './transformers/response-anthropic.
import { OpenAIResponseTransformer } from './transformers/response-openai.js';
import { getApiKey } from './auth.js';
import { getNextProxyAgent } from './proxy-manager.js';
import { logRequest as slsLogRequest } from './sls-logger.js';
import { buildDetailedLog } from './log-extractor.js';
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.
* Works for non-streaming responses.
@@ -52,7 +124,7 @@ function convertResponseToChatCompletion(resp) {
router.get('/v1/models', (req, res) => {
logInfo('GET /v1/models');
try {
const config = getConfig();
const models = config.models.map(model => ({
@@ -81,6 +153,7 @@ router.get('/v1/models', (req, res) => {
// 标准 OpenAI 聊天补全处理函数(带格式转换)
async function handleChatCompletions(req, res) {
logInfo('POST /v1/chat/completions');
const startTime = Date.now();
try {
const openaiRequest = req.body;
@@ -108,7 +181,7 @@ async function handleChatCompletions(req, res) {
authHeader = await getApiKey(req.headers.authorization);
} catch (error) {
logError('Failed to get API key', error);
return res.status(500).json({
return res.status(500).json({
error: 'API key not available',
message: 'Failed to get or refresh API key. Please check server logs.'
});
@@ -129,16 +202,19 @@ async function handleChatCompletions(req, res) {
// Update request body with redirected model ID before transformation
const requestWithRedirectedModel = { ...openaiRequest, model: modelId };
// Get provider from model config
const provider = getModelProvider(modelId);
if (model.type === 'anthropic') {
transformedRequest = transformToAnthropic(requestWithRedirectedModel);
const isStreaming = openaiRequest.stream === true;
headers = getAnthropicHeaders(authHeader, clientHeaders, isStreaming, modelId);
headers = getAnthropicHeaders(authHeader, clientHeaders, isStreaming, modelId, provider);
} else if (model.type === 'openai') {
transformedRequest = transformToOpenAI(requestWithRedirectedModel);
headers = getOpenAIHeaders(authHeader, clientHeaders);
headers = getOpenAIHeaders(authHeader, clientHeaders, provider);
} else if (model.type === 'common') {
transformedRequest = transformToCommon(requestWithRedirectedModel);
headers = getCommonHeaders(authHeader, clientHeaders);
headers = getCommonHeaders(authHeader, clientHeaders, provider);
} else {
return res.status(500).json({ error: `Unknown endpoint type: ${model.type}` });
}
@@ -163,9 +239,9 @@ async function handleChatCompletions(req, res) {
if (!response.ok) {
const errorText = await response.text();
logError(`Endpoint error: ${response.status}`, new Error(errorText));
return res.status(response.status).json({
return res.status(response.status).json({
error: `Endpoint returned ${response.status}`,
details: errorText
details: errorText
});
}
@@ -180,10 +256,18 @@ async function handleChatCompletions(req, res) {
if (model.type === 'common') {
try {
for await (const chunk of response.body) {
res.write(chunk);
res.write(maskStreamChunk(chunk));
}
res.end();
logInfo('Stream forwarded (common type)');
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/chat/completions',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req
}));
} catch (streamError) {
logError('Stream error', streamError);
res.end();
@@ -199,10 +283,18 @@ async function handleChatCompletions(req, res) {
try {
for await (const chunk of transformer.transformStream(response.body)) {
res.write(chunk);
res.write(maskStreamChunk(chunk));
}
res.end();
logInfo('Stream completed');
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/chat/completions',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req
}));
} catch (streamError) {
logError('Stream error', streamError);
res.end();
@@ -213,25 +305,46 @@ async function handleChatCompletions(req, res) {
if (model.type === 'openai') {
try {
const converted = convertResponseToChatCompletion(data);
logResponse(200, null, converted);
res.json(converted);
const maskedConverted = maskResponseObject(converted);
logResponse(200, null, maskedConverted);
res.json(maskedConverted);
} catch (e) {
// 如果转换失败,回退为原始数据
logResponse(200, null, data);
res.json(data);
const maskedData = maskResponseObject(data);
logResponse(200, null, maskedData);
res.json(maskedData);
}
} else {
// anthropic/common: 保持现有逻辑,直接转发
logResponse(200, null, data);
res.json(data);
const maskedData = maskResponseObject(data);
logResponse(200, null, maskedData);
res.json(maskedData);
}
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/chat/completions',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req,
responseData: data
}));
}
} catch (error) {
logError('Error in /v1/chat/completions', error);
res.status(500).json({
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({
error: 'Internal server error',
message: error.message
message: error.message
});
}
}
@@ -239,6 +352,7 @@ async function handleChatCompletions(req, res) {
// 直接转发 OpenAI 请求(不做格式转换)
async function handleDirectResponses(req, res) {
logInfo('POST /v1/responses');
const startTime = Date.now();
try {
const openaiRequest = req.body;
@@ -255,7 +369,7 @@ async function handleDirectResponses(req, res) {
// 只允许 openai 类型端点
if (model.type !== 'openai') {
return res.status(400).json({
return res.status(400).json({
error: 'Invalid endpoint type',
message: `/v1/responses 接口只支持 openai 类型端点,当前模型 ${modelId}${model.type} 类型`
});
@@ -277,16 +391,19 @@ async function handleDirectResponses(req, res) {
authHeader = await getApiKey(req.headers.authorization || clientAuthFromXApiKey);
} catch (error) {
logError('Failed to get API key', error);
return res.status(500).json({
return res.status(500).json({
error: 'API key not available',
message: 'Failed to get or refresh API key. Please check server logs.'
});
}
const clientHeaders = req.headers;
// Get provider from model config
const provider = getModelProvider(modelId);
// 获取 headers
const headers = getOpenAIHeaders(authHeader, clientHeaders);
const headers = getOpenAIHeaders(authHeader, clientHeaders, provider);
// 注入系统提示到 instructions 字段并更新重定向后的模型ID
const systemPrompt = getSystemPrompt();
@@ -306,7 +423,7 @@ async function handleDirectResponses(req, res) {
if (reasoningLevel === 'auto') {
// Auto模式保持原始请求的reasoning字段不变
// 如果原始请求有reasoning字段就保留没有就不添加
} else if (reasoningLevel && ['low', 'medium', 'high'].includes(reasoningLevel)) {
} else if (reasoningLevel && ['low', 'medium', 'high', 'xhigh'].includes(reasoningLevel)) {
modifiedRequest.reasoning = {
effort: reasoningLevel,
summary: 'auto'
@@ -316,6 +433,29 @@ async function handleDirectResponses(req, res) {
delete modifiedRequest.reasoning;
}
// 删除 claude-cli 特有字段,避免 Factory API 返回 403
delete modifiedRequest.context_management;
// 过滤 Claude Code 特有的 MCP 工具
if (modifiedRequest.tools && Array.isArray(modifiedRequest.tools)) {
modifiedRequest.tools = modifiedRequest.tools.filter(tool => {
if (!tool.name) return true;
// 过滤 Claude Code 特有工具
const claudeCodeTools = [
'Skill',
'EnterPlanMode',
'ExitPlanMode',
'AskUserQuestion',
'TodoWrite'
];
if (claudeCodeTools.includes(tool.name)) return false;
// 过滤所有 mcp__ 开头的工具和 MCP 相关工具
return !tool.name.startsWith('mcp__') &&
!tool.name.includes('Mcp') &&
!tool.name.includes('MCP');
});
}
logRequest('POST', endpoint.base_url, headers, modifiedRequest);
// 转发修改后的请求
@@ -337,9 +477,9 @@ async function handleDirectResponses(req, res) {
if (!response.ok) {
const errorText = await response.text();
logError(`Endpoint error: ${response.status}`, new Error(errorText));
return res.status(response.status).json({
return res.status(response.status).json({
error: `Endpoint returned ${response.status}`,
details: errorText
details: errorText
});
}
@@ -354,26 +494,53 @@ async function handleDirectResponses(req, res) {
try {
// 直接将原始响应流转发给客户端
for await (const chunk of response.body) {
res.write(chunk);
res.write(maskStreamChunk(chunk));
}
res.end();
logInfo('Stream forwarded successfully');
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/responses',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req
}));
} catch (streamError) {
logError('Stream error', streamError);
res.end();
}
} else {
// 直接转发非流式响应,不做任何转换
// 直接转发非流式响应
const data = await response.json();
logResponse(200, null, data);
res.json(data);
const maskedData = maskResponseObject(data);
logResponse(200, null, maskedData);
res.json(maskedData);
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/responses',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req,
responseData: data
}));
}
} catch (error) {
logError('Error in /v1/responses', error);
res.status(500).json({
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({
error: 'Internal server error',
message: error.message
message: error.message
});
}
}
@@ -381,6 +548,7 @@ async function handleDirectResponses(req, res) {
// 直接转发 Anthropic 请求(不做格式转换)
async function handleDirectMessages(req, res) {
logInfo('POST /v1/messages');
const startTime = Date.now();
try {
const anthropicRequest = req.body;
@@ -397,7 +565,7 @@ async function handleDirectMessages(req, res) {
// 只允许 anthropic 类型端点
if (model.type !== 'anthropic') {
return res.status(400).json({
return res.status(400).json({
error: 'Invalid endpoint type',
message: `/v1/messages 接口只支持 anthropic 类型端点,当前模型 ${modelId}${model.type} 类型`
});
@@ -419,17 +587,20 @@ async function handleDirectMessages(req, res) {
authHeader = await getApiKey(req.headers.authorization || clientAuthFromXApiKey);
} catch (error) {
logError('Failed to get API key', error);
return res.status(500).json({
return res.status(500).json({
error: 'API key not available',
message: 'Failed to get or refresh API key. Please check server logs.'
});
}
const clientHeaders = req.headers;
// Get provider from model config
const provider = getModelProvider(modelId);
// 获取 headers
const isStreaming = anthropicRequest.stream === true;
const headers = getAnthropicHeaders(authHeader, clientHeaders, isStreaming, modelId);
const headers = getAnthropicHeaders(authHeader, clientHeaders, isStreaming, modelId, provider);
// 注入系统提示到 system 字段并更新重定向后的模型ID
const systemPrompt = getSystemPrompt();
@@ -449,18 +620,45 @@ async function handleDirectMessages(req, res) {
}
}
// 将 Claude Code 格式转换为 Droid 格式 (修复 403 错误)
if (modifiedRequest.system && Array.isArray(modifiedRequest.system)) {
modifiedRequest.system = modifiedRequest.system.map((item, index) => {
const newItem = { ...item };
// 删除所有 cache_control
if (newItem.cache_control) {
delete newItem.cache_control;
}
// 过滤所有 system 项中的敏感词
if (newItem.text && typeof newItem.text === 'string') {
// 替换敏感词以避免 403 错误
newItem.text = newItem.text
.replace(/Claude Code/g, 'AI Assistant')
.replace(/claude code/g, 'AI assistant')
.replace(/\bClaude\b/g, 'AI')
.replace(/\bclaude\b/g, 'AI')
.replace(/Anthropic/g, 'Factory')
.replace(/anthropic/g, 'factory');
}
return newItem;
});
}
// 处理thinking字段
const reasoningLevel = getModelReasoning(modelId);
if (reasoningLevel === 'auto') {
// Auto模式保持原始请求的thinking字段不变
// 如果原始请求有thinking字段就保留没有就不添加
} else if (reasoningLevel && ['low', 'medium', 'high'].includes(reasoningLevel)) {
} else if (reasoningLevel && ['low', 'medium', 'high', 'xhigh'].includes(reasoningLevel)) {
const budgetTokens = {
'low': 4096,
'medium': 12288,
'high': 24576
'high': 24576,
'xhigh': 40960
};
modifiedRequest.thinking = {
type: 'enabled',
budget_tokens: budgetTokens[reasoningLevel]
@@ -470,6 +668,57 @@ async function handleDirectMessages(req, res) {
delete modifiedRequest.thinking;
}
// 过滤 messages 中的 Claude Code 特有标识
if (modifiedRequest.messages && Array.isArray(modifiedRequest.messages)) {
modifiedRequest.messages = modifiedRequest.messages.map(msg => {
if (msg.content && Array.isArray(msg.content)) {
msg.content = msg.content.filter(item => {
if (item.type === 'text' && item.text) {
// 过滤包含 Claude Code 特征的内容
const claudeCodePatterns = [
'<system-reminder>',
'# claudeMd',
'/Users/',
'/.claude/',
'CLAUDE.md',
'<command-name>',
'<command-message>',
'<local-command-stdout>',
'CodeX MCP',
'codex MCP'
];
return !claudeCodePatterns.some(pattern => item.text.includes(pattern));
}
return true;
});
}
return msg;
});
}
// 删除 claude-cli 特有字段,避免 Factory API 返回 403
delete modifiedRequest.context_management;
// 过滤 Claude Code 特有的 MCP 工具
if (modifiedRequest.tools && Array.isArray(modifiedRequest.tools)) {
modifiedRequest.tools = modifiedRequest.tools.filter(tool => {
if (!tool.name) return true;
// 过滤 Claude Code 特有工具
const claudeCodeTools = [
'Skill',
'EnterPlanMode',
'ExitPlanMode',
'AskUserQuestion',
'TodoWrite'
];
if (claudeCodeTools.includes(tool.name)) return false;
// 过滤所有 mcp__ 开头的工具和 MCP 相关工具
return !tool.name.startsWith('mcp__') &&
!tool.name.includes('Mcp') &&
!tool.name.includes('MCP');
});
}
logRequest('POST', endpoint.base_url, headers, modifiedRequest);
// 转发修改后的请求
@@ -491,9 +740,9 @@ async function handleDirectMessages(req, res) {
if (!response.ok) {
const errorText = await response.text();
logError(`Endpoint error: ${response.status}`, new Error(errorText));
return res.status(response.status).json({
return res.status(response.status).json({
error: `Endpoint returned ${response.status}`,
details: errorText
details: errorText
});
}
@@ -506,23 +755,50 @@ async function handleDirectMessages(req, res) {
try {
// 直接将原始响应流转发给客户端
for await (const chunk of response.body) {
res.write(chunk);
res.write(maskStreamChunk(chunk));
}
res.end();
logInfo('Stream forwarded successfully');
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/messages',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req
}));
} catch (streamError) {
logError('Stream error', streamError);
res.end();
}
} else {
// 直接转发非流式响应,不做任何转换
// 直接转发非流式响应
const data = await response.json();
logResponse(200, null, data);
res.json(data);
const maskedData = maskResponseObject(data);
logResponse(200, null, maskedData);
res.json(maskedData);
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/messages',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req,
responseData: data
}));
}
} catch (error) {
logError('Error in /v1/messages', error);
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({
error: 'Internal server error',
message: error.message
@@ -533,6 +809,7 @@ async function handleDirectMessages(req, res) {
// 处理 Anthropic count_tokens 请求
async function handleCountTokens(req, res) {
logInfo('POST /v1/messages/count_tokens');
const startTime = Date.now();
try {
const anthropicRequest = req.body;
@@ -576,7 +853,11 @@ async function handleCountTokens(req, res) {
}
const clientHeaders = req.headers;
const headers = getAnthropicHeaders(authHeader, clientHeaders, false, modelId);
// Get provider from model config
const provider = getModelProvider(modelId);
const headers = getAnthropicHeaders(authHeader, clientHeaders, false, modelId, provider);
// 构建 count_tokens 端点 URL
const countTokensUrl = endpoint.base_url.replace('/v1/messages', '/v1/messages/count_tokens');
@@ -614,9 +895,27 @@ async function handleCountTokens(req, res) {
const data = await response.json();
logResponse(200, null, data);
res.json(data);
slsLogRequest(buildDetailedLog({
method: 'POST',
endpoint: '/v1/messages/count_tokens',
model: modelId,
status: 200,
duration_ms: Date.now() - startTime,
req,
responseData: data
}));
} catch (error) {
logError('Error in /v1/messages/count_tokens', error);
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({
error: 'Internal server error',
message: error.message

173
server.js
View File

@@ -1,25 +1,156 @@
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 router from './routes.js';
import { initializeAuth } from './auth.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();
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
/**
* CORS 中间件 - 根据配置动态处理跨域请求
*/
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, anthropic-version');
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', '*');
} else if (origin && corsConfig.origins.length > 0) {
// 白名单模式:验证请求来源
if (corsConfig.origins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Vary', 'Origin');
}
// 不在白名单中的请求不设置 CORS 头,浏览器会拒绝
}
// 如果没有配置 origins 且不是 allowAll则不设置任何 CORS 头
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
return res.sendStatus(204);
}
next();
});
// 请求认证中间件
app.use(authMiddleware);
app.use(router);
app.get('/', (req, res) => {
@@ -56,6 +187,11 @@ app.use((req, res, next) => {
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('❌ 非法请求地址');
console.error('='.repeat(80));
@@ -65,23 +201,23 @@ app.use((req, res, next) => {
console.error(`路径: ${errorInfo.path}`);
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) {
console.error(`请求体: ${JSON.stringify(errorInfo.body, null, 2)}`);
console.error(`请求体: ${JSON.stringify(safeBody, null, 2)}`);
}
console.error(`客户端IP: ${errorInfo.ip}`);
console.error(`User-Agent: ${errorInfo.headers['user-agent'] || 'N/A'}`);
console.error(`客户端IP: ${safeIp}`);
console.error(`User-Agent: ${safeHeaders['user-agent'] || 'N/A'}`);
if (errorInfo.headers.referer) {
console.error(`来源: ${errorInfo.headers.referer}`);
if (safeHeaders.referer) {
console.error(`来源: ${safeHeaders.referer}`);
}
console.error('='.repeat(80) + '\n');
logError('Invalid request path', errorInfo);
logError('Invalid request path', sanitizeForLog(errorInfo));
res.status(404).json({
error: 'Not Found',
@@ -111,6 +247,17 @@ app.use((err, req, res, next) => {
loadConfig();
logInfo('Configuration loaded successfully');
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
initializeUserAgentUpdater();
// Initialize auth system (load and setup API key if needed)
// This won't throw error if no auth config is found - will use client auth

154
sls-logger.js Normal file
View File

@@ -0,0 +1,154 @@
/**
* 阿里云日志服务SLS日志模块
*
* 功能:
* - 将 API 请求/响应日志上报到阿里云 SLS
* - 批量上报,减少 API 调用
* - 环境变量缺失时静默降级
*/
import ALSClient from 'aliyun-log';
import { sanitizeForLog } from './log-sanitizer.js';
// SLS 配置
const SLS_CONFIG = {
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET,
endpoint: process.env.ALIYUN_SLS_ENDPOINT,
project: process.env.ALIYUN_SLS_PROJECT,
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 = SLS_ENABLED && Object.values(SLS_CONFIG).every(v => v);
let client = null;
let logQueue = [];
const BATCH_SIZE = 10;
const FLUSH_INTERVAL_MS = 5000;
// 初始化 SLS Client
function initClient() {
if (!SLS_ENABLED) {
return null;
}
if (!isConfigured) {
console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台');
return null;
}
try {
client = new ALSClient({
accessKeyId: SLS_CONFIG.accessKeyId,
accessKeySecret: SLS_CONFIG.accessKeySecret,
endpoint: SLS_CONFIG.endpoint
});
console.log('[SLS] 阿里云日志服务客户端初始化成功');
return client;
} catch (error) {
console.error('[SLS] 初始化失败:', error.message);
return null;
}
}
// 刷新日志队列
async function flushLogs() {
if (!client || logQueue.length === 0) return;
const logsToSend = logQueue.splice(0, BATCH_SIZE);
try {
const logs = logsToSend.map(log => ({
timestamp: Math.floor(Date.now() / 1000),
content: Object.fromEntries(
Object.entries(log).map(([key, value]) => [key, String(value ?? '')])
)
}));
await client.postLogStoreLogs(SLS_CONFIG.project, SLS_CONFIG.logstore, { logs });
console.log(`[SLS] 成功上报 ${logsToSend.length} 条日志`);
} catch (error) {
console.error('[SLS] 日志上报失败:', error.message);
// 失败的日志重新入队(可选:限制重试次数)
logQueue.unshift(...logsToSend);
}
}
// 定时刷新
let flushTimer = null;
function startFlushTimer() {
if (!SLS_ENABLED || flushTimer || !isConfigured) return;
flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS);
}
/**
* 记录 API 请求日志
* @param {Object} logData - 日志数据
* @param {string} logData.method - HTTP 方法
* @param {string} logData.endpoint - 请求路径
* @param {string} logData.model - 模型 ID
* @param {number} logData.status - 响应状态码
* @param {number} logData.duration_ms - 请求耗时
* @param {number} [logData.input_tokens] - 输入 Token 数
* @param {number} [logData.output_tokens] - 输出 Token 数
* @param {string} [logData.error] - 错误信息
*/
export function logRequest(logData) {
if (!SLS_ENABLED) return;
const enrichedLog = sanitizeForLog({
timestamp: new Date().toISOString(),
...logData
});
// 始终输出到控制台
console.log('[SLS]', JSON.stringify(enrichedLog));
if (!isConfigured) return;
logQueue.push(enrichedLog);
// 队列满时立即刷新
if (logQueue.length >= BATCH_SIZE) {
flushLogs();
}
}
/**
* 优雅关闭,刷新剩余日志
*/
export async function shutdown() {
if (!SLS_ENABLED) return;
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
await flushLogs();
console.log('[SLS] 已关闭');
}
// 初始化
initClient();
startFlushTimer();
// 进程退出时优雅关闭
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
export default {
logRequest,
shutdown
};

View File

@@ -1,4 +1,8 @@
#!/bin/bash
echo "FACTORY_API_KEY 当前值是" $FACTORY_API_KEY
echo $FACTORY_API_KEY
echo "Reset FACTORY_API_KEY..."
export FACTORY_API_KEY=""
echo "Starting droid2api server..."
node server.js

235
sync-accounts.sh Executable file
View File

@@ -0,0 +1,235 @@
#!/bin/bash
#
# sync-accounts.sh - 账号配置增量同步脚本
#
# 用途:从本地机器增量同步 accounts.json 到远程无头服务器
#
# 使用方式:
# ./sync-accounts.sh # 使用默认配置
# ./sync-accounts.sh user@server.com # 指定服务器
# ./sync-accounts.sh user@server.com /path/to/droid2api # 指定路径
#
# 前置要求:
# 1. 配置 SSH 免密登录ssh-copy-id user@server.com
# 2. 远程服务器安装 jqapt install jq 或 yum install jq
#
set -e
# ========== 配置区域 ==========
# 优先从 .env 文件读取,可通过环境变量或命令行参数覆盖
# 加载 .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"
# 部署方式: pm2 | docker | docker-compose | none
DEPLOY_TYPE="${DEPLOY_TYPE:-docker-compose}"
DOCKER_SERVICE_NAME="${DOCKER_SERVICE_NAME:-droid2api}"
PM2_APP_NAME="${PM2_APP_NAME:-droid2api}"
# ==============================
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 解析命令行参数
SERVER="${1:-$DEFAULT_SERVER}"
REMOTE_PATH="${2:-$DEFAULT_REMOTE_PATH}"
REMOTE_FILE="$REMOTE_PATH/accounts.json"
# 显示帮助
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
echo "用法: $0 [user@server] [remote_path]"
echo ""
echo "环境变量:"
echo " DEPLOY_TYPE 部署方式: pm2 | docker | docker-compose | none (默认: docker-compose)"
echo " DOCKER_SERVICE_NAME Docker 服务名 (默认: droid2api)"
echo " PM2_APP_NAME PM2 应用名 (默认: droid2api)"
echo ""
echo "示例:"
echo " $0 root@192.168.1.100 /opt/droid2api"
echo " DEPLOY_TYPE=pm2 $0 user@server.com"
exit 0
fi
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo " 📦 账号配置增量同步工具"
echo "═══════════════════════════════════════════════════════════════"
echo ""
# 检查本地文件是否存在
if [[ ! -f "$LOCAL_FILE" ]]; then
log_error "本地文件 $LOCAL_FILE 不存在"
log_info "请先运行 'node add-account.js' 添加账号"
exit 1
fi
# 显示连接信息
log_info "目标服务器: $SERVER"
log_info "远程路径: $REMOTE_FILE"
log_info "部署方式: $DEPLOY_TYPE"
echo ""
# 创建临时目录
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
# Step 1: 下载远程现有配置
log_info "正在获取远程现有配置..."
if ssh "$SERVER" "test -f $REMOTE_FILE" 2>/dev/null; then
scp -q "$SERVER:$REMOTE_FILE" "$TEMP_DIR/remote_accounts.json"
REMOTE_COUNT=$(jq '.accounts | length' "$TEMP_DIR/remote_accounts.json" 2>/dev/null || echo 0)
log_success "远程现有 $REMOTE_COUNT 个账号"
else
log_warn "远程配置不存在,将创建新文件"
echo '{"accounts":[],"settings":{}}' > "$TEMP_DIR/remote_accounts.json"
REMOTE_COUNT=0
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: 显示本地账号数量
LOCAL_COUNT=$(jq '.accounts | length' "$LOCAL_FILE")
log_info "本地现有 $LOCAL_COUNT 个账号"
# Step 3: 合并配置(按 email 去重,本地优先)
log_info "正在合并配置..."
jq -s '
# 使用第一个文件(远程)的 accounts 作为基础
(.[0].accounts // []) as $remote |
# 使用第二个文件(本地)的 accounts
(.[1].accounts // []) as $local |
# 获取远程账号的 email 列表
($remote | map(.email)) as $remote_emails |
# 过滤出本地新增的账号email 不在远程列表中的)
($local | map(select(.email as $e | $remote_emails | index($e) | not))) as $new_local |
# 更新远程已存在的账号(用本地数据覆盖同 email 的远程账号)
($remote | map(
. as $r |
($local | map(select(.email == $r.email)) | .[0]) as $l |
if $l then $l else $r end
)) as $updated_remote |
# 合并:更新后的远程 + 本地新增
{
accounts: ($updated_remote + $new_local),
settings: (.[1].settings // .[0].settings // {}),
last_updated: (now | strftime("%Y-%m-%dT%H:%M:%SZ"))
}
' "$TEMP_DIR/remote_accounts.json" "$LOCAL_FILE" > "$TEMP_DIR/merged_accounts.json"
MERGED_COUNT=$(jq '.accounts | length' "$TEMP_DIR/merged_accounts.json")
NEW_COUNT=$((MERGED_COUNT - REMOTE_COUNT))
if [[ $NEW_COUNT -gt 0 ]]; then
log_success "新增 $NEW_COUNT 个账号,合并后共 $MERGED_COUNT 个账号"
else
log_info "无新增账号,共 $MERGED_COUNT 个账号"
fi
# Step 4: 显示账号列表
echo ""
log_info "合并后的账号列表:"
echo "──────────────────────────────────────────────────────────────"
jq -r '.accounts[] | " \(.status == "active" | if . then "🟢" else "🔴" end) \(.email // .name) [\(.id)]"' "$TEMP_DIR/merged_accounts.json"
echo "──────────────────────────────────────────────────────────────"
echo ""
# Step 5: 确认上传
read -p "确认上传到服务器? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_warn "已取消"
exit 0
fi
# Step 6: 上传合并后的配置
log_info "正在上传配置..."
scp -q "$TEMP_DIR/merged_accounts.json" "$SERVER:$REMOTE_FILE"
log_success "配置已上传"
# Step 7: 重启服务(根据部署方式)
echo ""
case "$DEPLOY_TYPE" in
pm2)
log_info "正在重启 PM2 服务..."
ssh "$SERVER" "pm2 restart $PM2_APP_NAME" 2>/dev/null && \
log_success "PM2 服务已重启" || \
log_warn "PM2 重启失败,请手动重启"
;;
docker)
log_info "正在重启 Docker 容器..."
ssh "$SERVER" "docker restart $DOCKER_SERVICE_NAME" 2>/dev/null && \
log_success "Docker 容器已重启" || \
log_warn "Docker 重启失败,请手动重启"
;;
docker-compose)
log_info "正在重启 Docker Compose 服务..."
ssh "$SERVER" "cd $REMOTE_PATH && docker compose restart" 2>/dev/null && \
log_success "Docker Compose 服务已重启" || \
log_warn "Docker Compose 重启失败,请手动重启"
;;
none)
log_warn "已跳过服务重启,请手动重启服务以生效"
;;
*)
log_warn "未知的部署方式: $DEPLOY_TYPE,请手动重启服务"
;;
esac
echo ""
echo "═══════════════════════════════════════════════════════════════"
log_success "同步完成!"
echo "═══════════════════════════════════════════════════════════════"
echo ""

View 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);
});

View File

@@ -1,6 +1,32 @@
import { logDebug } from '../logger.js';
import { getSystemPrompt, getModelReasoning, getUserAgent } from '../config.js';
/**
* Filter sensitive keywords from system prompt text to avoid 403 errors
*/
function filterSensitiveKeywords(text) {
if (typeof text !== 'string') return text;
// Replace sensitive keywords with generic alternatives
const replacements = {
'Claude Code': 'AI Assistant',
'claude code': 'AI assistant',
'Claude': 'AI',
'claude': 'AI',
'Anthropic': 'Factory',
'anthropic': 'factory'
};
let filtered = text;
for (const [keyword, replacement] of Object.entries(replacements)) {
// Use word boundary to avoid replacing parts of words
const regex = new RegExp(`\\b${keyword}\\b`, 'g');
filtered = filtered.replace(regex, replacement);
}
return filtered;
}
export function transformToAnthropic(openaiRequest) {
logDebug('Transforming OpenAI request to Anthropic format');
@@ -33,14 +59,14 @@ export function transformToAnthropic(openaiRequest) {
if (typeof msg.content === 'string') {
systemContent.push({
type: 'text',
text: msg.content
text: filterSensitiveKeywords(msg.content)
});
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === 'text') {
systemContent.push({
type: 'text',
text: part.text
text: filterSensitiveKeywords(part.text)
});
} else {
systemContent.push(part);
@@ -90,10 +116,10 @@ export function transformToAnthropic(openaiRequest) {
if (systemPrompt) {
anthropicRequest.system.push({
type: 'text',
text: systemPrompt
text: filterSensitiveKeywords(systemPrompt)
});
}
// Add user-provided system content
// Add user-provided system content (already filtered above)
anthropicRequest.system.push(...systemContent);
}
@@ -119,12 +145,13 @@ export function transformToAnthropic(openaiRequest) {
anthropicRequest.thinking = openaiRequest.thinking;
}
// If original request has no thinking field, don't add one
} else if (reasoningLevel && ['low', 'medium', 'high'].includes(reasoningLevel)) {
} else if (reasoningLevel && ['low', 'medium', 'high', 'xhigh'].includes(reasoningLevel)) {
// Specific level: override with model configuration
const budgetTokens = {
'low': 4096,
'medium': 12288,
'high': 24576
'high': 24576,
'xhigh': 24576
};
anthropicRequest.thinking = {
@@ -154,7 +181,7 @@ export function transformToAnthropic(openaiRequest) {
return anthropicRequest;
}
export function getAnthropicHeaders(authHeader, clientHeaders = {}, isStreaming = true, modelId = null) {
export function getAnthropicHeaders(authHeader, clientHeaders = {}, isStreaming = true, modelId = null, provider = 'anthropic') {
// Generate unique IDs if not provided
const sessionId = clientHeaders['x-session-id'] || generateUUID();
const messageId = clientHeaders['x-assistant-message-id'] || generateUUID();
@@ -165,7 +192,7 @@ export function getAnthropicHeaders(authHeader, clientHeaders = {}, isStreaming
'anthropic-version': clientHeaders['anthropic-version'] || '2023-06-01',
'authorization': authHeader || '',
'x-api-key': 'placeholder',
'x-api-provider': 'anthropic',
'x-api-provider': provider,
'x-factory-client': 'cli',
'x-session-id': sessionId,
'x-assistant-message-id': messageId,
@@ -177,19 +204,23 @@ export function getAnthropicHeaders(authHeader, clientHeaders = {}, isStreaming
// Handle anthropic-beta header based on reasoning configuration
const reasoningLevel = modelId ? getModelReasoning(modelId) : null;
let betaValues = [];
// Add existing beta values from client headers
if (clientHeaders['anthropic-beta']) {
const existingBeta = clientHeaders['anthropic-beta'];
betaValues = existingBeta.split(',').map(v => v.trim());
}
// Filter out Claude Code specific beta values to avoid 403
const claudeCodeBetas = ['claude-code-20250219', 'context-management-2025-06-27'];
betaValues = betaValues.filter(v => !claudeCodeBetas.includes(v));
// Handle thinking beta based on reasoning configuration
const thinkingBeta = 'interleaved-thinking-2025-05-14';
if (reasoningLevel === 'auto') {
// Auto mode: don't modify anthropic-beta header, preserve original
// betaValues remain unchanged from client headers
} else if (reasoningLevel && ['low', 'medium', 'high'].includes(reasoningLevel)) {
} else if (reasoningLevel && ['low', 'medium', 'high', 'xhigh'].includes(reasoningLevel)) {
// Add thinking beta if not already present
if (!betaValues.includes(thinkingBeta)) {
betaValues.push(thinkingBeta);
@@ -198,7 +229,7 @@ export function getAnthropicHeaders(authHeader, clientHeaders = {}, isStreaming
// Remove thinking beta if reasoning is off/invalid
betaValues = betaValues.filter(v => v !== thinkingBeta);
}
// Set anthropic-beta header if there are any values
if (betaValues.length > 0) {
headers['anthropic-beta'] = betaValues.join(', ');

View File

@@ -1,5 +1,5 @@
import { logDebug } from '../logger.js';
import { getSystemPrompt, getUserAgent } from '../config.js';
import { getSystemPrompt, getUserAgent, getModelReasoning } from '../config.js';
export function transformToCommon(openaiRequest) {
logDebug('Transforming OpenAI request to Common format');
@@ -39,11 +39,25 @@ export function transformToCommon(openaiRequest) {
}
}
// Handle reasoning_effort field based on model configuration
const reasoningLevel = getModelReasoning(openaiRequest.model);
if (reasoningLevel === 'auto') {
// Auto mode: preserve original request's reasoning_effort field exactly as-is
// If original request has reasoning_effort field, keep it; otherwise don't add one
} else if (reasoningLevel && ['low', 'medium', 'high', 'xhigh'].includes(reasoningLevel)) {
// Specific level: override with model configuration
commonRequest.reasoning_effort = reasoningLevel;
} else {
// Off or invalid: explicitly remove reasoning_effort field
// This ensures any reasoning_effort field from the original request is deleted
delete commonRequest.reasoning_effort;
}
logDebug('Transformed Common request', commonRequest);
return commonRequest;
}
export function getCommonHeaders(authHeader, clientHeaders = {}) {
export function getCommonHeaders(authHeader, clientHeaders = {}, provider = 'baseten') {
// Generate unique IDs if not provided
const sessionId = clientHeaders['x-session-id'] || generateUUID();
const messageId = clientHeaders['x-assistant-message-id'] || generateUUID();
@@ -52,7 +66,7 @@ export function getCommonHeaders(authHeader, clientHeaders = {}) {
'accept': 'application/json',
'content-type': 'application/json',
'authorization': authHeader || '',
'x-api-provider': 'baseten',
'x-api-provider': provider,
'x-factory-client': 'cli',
'x-session-id': sessionId,
'x-assistant-message-id': messageId,

View File

@@ -100,7 +100,7 @@ export function transformToOpenAI(openaiRequest) {
targetRequest.reasoning = openaiRequest.reasoning;
}
// If original request has no reasoning field, don't add one
} else if (reasoningLevel && ['low', 'medium', 'high'].includes(reasoningLevel)) {
} else if (reasoningLevel && ['low', 'medium', 'high', 'xhigh'].includes(reasoningLevel)) {
// Specific level: override with model configuration
targetRequest.reasoning = {
effort: reasoningLevel,
@@ -133,7 +133,7 @@ export function transformToOpenAI(openaiRequest) {
return targetRequest;
}
export function getOpenAIHeaders(authHeader, clientHeaders = {}) {
export function getOpenAIHeaders(authHeader, clientHeaders = {}, provider = 'openai') {
// Generate unique IDs if not provided
const sessionId = clientHeaders['x-session-id'] || generateUUID();
const messageId = clientHeaders['x-assistant-message-id'] || generateUUID();
@@ -141,7 +141,7 @@ export function getOpenAIHeaders(authHeader, clientHeaders = {}) {
const headers = {
'content-type': 'application/json',
'authorization': authHeader || '',
'x-api-provider': 'azure_openai',
'x-api-provider': provider,
'x-factory-client': 'cli',
'x-session-id': sessionId,
'x-assistant-message-id': messageId,

113
user-agent-updater.js Normal file
View File

@@ -0,0 +1,113 @@
import https from 'https';
import { logInfo, logError } from './logger.js';
import { getConfig } from './config.js';
const VERSION_URL = 'https://downloads.factory.ai/factory-cli/LATEST';
const USER_AGENT_PREFIX = 'factory-cli';
const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
const RETRY_INTERVAL = 60 * 1000; // 1 minute
const MAX_RETRIES = 3;
let currentVersion = null;
let isUpdating = false;
function getDefaultVersion() {
const cfg = getConfig();
const userAgent = cfg.user_agent || 'factory-cli/0.40.2';
const match = userAgent.match(/\/(\d+\.\d+\.\d+)/);
return match ? match[1] : '0.40.2';
}
function initializeVersion() {
if (currentVersion === null) {
currentVersion = getDefaultVersion();
}
}
export function getCurrentUserAgent() {
initializeVersion();
return `${USER_AGENT_PREFIX}/${currentVersion}`;
}
function fetchLatestVersion() {
return new Promise((resolve, reject) => {
const request = https.get(VERSION_URL, (res) => {
let data = '';
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
const version = data.trim();
if (version && /^\d+\.\d+\.\d+/.test(version)) {
resolve(version);
} else {
reject(new Error(`Invalid version format: ${version}`));
}
});
});
request.on('error', (err) => {
reject(err);
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Request timeout'));
});
});
}
async function updateVersionWithRetry(retryCount = 0) {
if (isUpdating) {
return;
}
isUpdating = true;
try {
const version = await fetchLatestVersion();
if (version !== currentVersion) {
const oldVersion = currentVersion;
currentVersion = version;
logInfo(`User-Agent version updated: ${oldVersion} -> ${currentVersion}`);
} else {
logInfo(`User-Agent version is up to date: ${currentVersion}`);
}
isUpdating = false;
} catch (error) {
// Silently fail on network issues, will retry later
if (retryCount < MAX_RETRIES - 1) {
setTimeout(() => {
updateVersionWithRetry(retryCount + 1);
}, RETRY_INTERVAL);
} else {
// Only log after all retries failed
logInfo(`Using default User-Agent version: ${currentVersion}`);
isUpdating = false;
}
}
}
export function initializeUserAgentUpdater() {
initializeVersion();
logInfo('Initializing User-Agent version updater...');
logInfo(`Default User-Agent from config: ${USER_AGENT_PREFIX}/${currentVersion}`);
// Fetch immediately on startup
updateVersionWithRetry();
// Schedule hourly checks
setInterval(() => {
logInfo('Running scheduled User-Agent version check...');
updateVersionWithRetry();
}, CHECK_INTERVAL);
logInfo(`User-Agent updater initialized. Will check every hour.`);
}