Spaces:
Running
Running
Upload 24 files
Browse files- Dockerfile +12 -0
- README.md +257 -11
- config/setting.toml +29 -0
- docker-compose.yml +10 -0
- main.py +5 -0
- requirements.txt +5 -0
- src/__init__.py +0 -0
- src/api/__init__.py +0 -0
- src/api/admin.py +378 -0
- src/api/routes.py +673 -0
- src/core/__init__.py +0 -0
- src/core/auth.py +53 -0
- src/core/config.py +76 -0
- src/core/logger.py +27 -0
- src/core/models.py +38 -0
- src/main.py +106 -0
- src/services/__init__.py +0 -0
- src/services/api_key_manager.py +133 -0
- src/services/ob1_client.py +125 -0
- src/services/token_manager.py +315 -0
- static/login.html +50 -0
- static/manage.css +35 -0
- static/manage.html +255 -0
- static/manage.js +502 -0
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 8081
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8081"]
|
README.md
CHANGED
|
@@ -1,11 +1,257 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
# OB1-2API
|
| 4 |
+
|
| 5 |
+
**将 [OB-1](https://openblocklabs.com) AI 服务转为 OpenAI 兼容 API**
|
| 6 |
+
|
| 7 |
+
[快速开始](#快速开始) | [功能特性](#功能特性) | [配置说明](#配置说明) | [API 文档](#api-接口)
|
| 8 |
+
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
## 功能特性
|
| 12 |
+
|
| 13 |
+
- 🔄 **OpenAI 兼容** — `/v1/chat/completions`、`/v1/models`,直接对接主流客户端
|
| 14 |
+
- 🤖 **Anthropic Messages API** — `/v1/messages`,兼容 Claude Code 等 Anthropic 原生客户端
|
| 15 |
+
- 👥 **多账号轮换** — 缓存优先 / 平衡轮换 / 性能优先三种调度策略
|
| 16 |
+
- 🔐 **自动 Token 管理** — 基于 WorkOS OAuth 设备授权,自动续期,401 即时重试
|
| 17 |
+
- 📡 **流式输出** — 完整 SSE 流式响应,实时返回生成内容
|
| 18 |
+
- 🖥️ **Web 管理面板** — 账号、API Key、系统设置、设备授权一站式操作
|
| 19 |
+
- ⚡ **热重载配置** — 后台修改即时生效,无需重启服务
|
| 20 |
+
- 🌐 **代理支持** — HTTP 代理配置,可视化连通性测试
|
| 21 |
+
|
| 22 |
+
## 快速开始
|
| 23 |
+
|
| 24 |
+
### 直接运行
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
# 克隆项目
|
| 28 |
+
git clone https://github.com/longnghiemduc6-art/ob12api.git
|
| 29 |
+
cd ob12api
|
| 30 |
+
|
| 31 |
+
# 安装依赖
|
| 32 |
+
pip install -r requirements.txt
|
| 33 |
+
|
| 34 |
+
# 启动服务
|
| 35 |
+
python main.py
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### Docker 部署
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
docker run -d \
|
| 42 |
+
--name ob12api \
|
| 43 |
+
-p 8081:8081 \
|
| 44 |
+
-v ./config:/app/config \
|
| 45 |
+
-v ./data:/app/data \
|
| 46 |
+
ob12api
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### Docker Compose
|
| 50 |
+
|
| 51 |
+
```yaml
|
| 52 |
+
version: '3.8'
|
| 53 |
+
services:
|
| 54 |
+
ob12api:
|
| 55 |
+
build: .
|
| 56 |
+
ports:
|
| 57 |
+
- "8081:8081"
|
| 58 |
+
volumes:
|
| 59 |
+
- ./config:/app/config
|
| 60 |
+
- ./data:/app/data
|
| 61 |
+
restart: unless-stopped
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
服务启动后访问 `http://localhost:8081` 进入管理面板。
|
| 65 |
+
|
| 66 |
+
## 配置说明
|
| 67 |
+
|
| 68 |
+
编辑 `config/setting.toml`:
|
| 69 |
+
|
| 70 |
+
```toml
|
| 71 |
+
[global]
|
| 72 |
+
api_key = "your-api-key" # 客户端调用使用的 API Key
|
| 73 |
+
|
| 74 |
+
[server]
|
| 75 |
+
host = "0.0.0.0"
|
| 76 |
+
port = 8081
|
| 77 |
+
|
| 78 |
+
[admin]
|
| 79 |
+
username = "admin"
|
| 80 |
+
password = "admin" # ⚠️ 请务必修改默认密码
|
| 81 |
+
|
| 82 |
+
[proxy]
|
| 83 |
+
url = "" # HTTP 代理地址(可选)
|
| 84 |
+
|
| 85 |
+
[ob1]
|
| 86 |
+
rotation_mode = "cache-first" # 调度模式:cache-first / balanced / performance
|
| 87 |
+
|
| 88 |
+
[logging]
|
| 89 |
+
level = "INFO" # 日志级别:DEBUG / INFO / WARNING / ERROR
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
## 添加账号
|
| 93 |
+
|
| 94 |
+
进入管理面板后,支持两种方式添加 OB-1 账号:
|
| 95 |
+
|
| 96 |
+
| 方式 | 说明 |
|
| 97 |
+
|------|------|
|
| 98 |
+
| **设备授权** | 点击「设备授权」按钮,获取授权码后在 OB-1 网站完成授权 |
|
| 99 |
+
| **JSON 导入** | 批量导入已有账号的 JSON 数据 |
|
| 100 |
+
|
| 101 |
+
## 调度模式
|
| 102 |
+
|
| 103 |
+
| 模式 | 策略 | 适用场景 |
|
| 104 |
+
|------|------|----------|
|
| 105 |
+
| `cache-first` | 优先使用上次成功的账号,减少切换开销 | 稳定使用 |
|
| 106 |
+
| `balanced` | 轮流使用各账号,均衡分配请求负载 | 日常使用,延长账号寿命 |
|
| 107 |
+
| `performance` | 随机选择可用账号,分散请求压力 | 高并发场景 |
|
| 108 |
+
|
| 109 |
+
## API 接口
|
| 110 |
+
|
| 111 |
+
### 获取模型列表
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
curl http://localhost:8081/v1/models \
|
| 115 |
+
-H "Authorization: Bearer your-api-key"
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### 对话补全(流式)
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
curl http://localhost:8081/v1/chat/completions \
|
| 122 |
+
-H "Authorization: Bearer your-api-key" \
|
| 123 |
+
-H "Content-Type: application/json" \
|
| 124 |
+
-d '{
|
| 125 |
+
"model": "anthropic/claude-sonnet-4",
|
| 126 |
+
"messages": [{"role": "user", "content": "Hello"}],
|
| 127 |
+
"stream": true
|
| 128 |
+
}'
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### 对话补全(非流式)
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
curl http://localhost:8081/v1/chat/completions \
|
| 135 |
+
-H "Authorization: Bearer your-api-key" \
|
| 136 |
+
-H "Content-Type: application/json" \
|
| 137 |
+
-d '{
|
| 138 |
+
"model": "anthropic/claude-sonnet-4",
|
| 139 |
+
"messages": [{"role": "user", "content": "Hello"}],
|
| 140 |
+
"stream": false
|
| 141 |
+
}'
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
## 项目结构
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
ob12api/
|
| 148 |
+
├── main.py # 启动入口
|
| 149 |
+
├── requirements.txt # Python 依赖
|
| 150 |
+
├── config/
|
| 151 |
+
│ ├── setting.toml # 配置文件
|
| 152 |
+
│ ├── accounts.json # 账号数据(自动生成)
|
| 153 |
+
│ └── api_keys.json # API Key 数据(自动生成)
|
| 154 |
+
├── data/
|
| 155 |
+
│ └── tokens.json # OAuth Token 存储
|
| 156 |
+
├── src/
|
| 157 |
+
│ ├── main.py # FastAPI 应用
|
| 158 |
+
│ ├── api/
|
| 159 |
+
│ │ ├── routes.py # OpenAI 兼容路由
|
| 160 |
+
│ │ └── admin.py # 管理后台接口
|
| 161 |
+
│ ├── core/
|
| 162 |
+
│ │ ├── config.py # 配置加载(热重载)
|
| 163 |
+
│ │ ├── auth.py # 认证鉴权
|
| 164 |
+
│ │ ├── models.py # 请求/响应模型
|
| 165 |
+
│ │ └── logger.py # 日志系统
|
| 166 |
+
│ └── services/
|
| 167 |
+
│ ├── token_manager.py # Token 生命周期管理
|
| 168 |
+
│ ├── ob1_client.py # OB-1 API 客户端
|
| 169 |
+
│ └── api_key_manager.py # API Key 管理
|
| 170 |
+
└── static/ # 管理面板前端资源
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
## ���见问题
|
| 174 |
+
|
| 175 |
+
### Docker 相关
|
| 176 |
+
|
| 177 |
+
**Q: Docker 部署后设备授权报错 400**
|
| 178 |
+
|
| 179 |
+
确保容器能访问外网(WorkOS API)。如需代理,在 `config/setting.toml` 中配置:
|
| 180 |
+
|
| 181 |
+
```toml
|
| 182 |
+
[proxy]
|
| 183 |
+
url = "http://your-proxy:7890"
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
或在 Docker 启动时传入网络代理环境变量。
|
| 187 |
+
|
| 188 |
+
**Q: Docker 重启后管理面板需要重新登录**
|
| 189 |
+
|
| 190 |
+
这是正常现象。管理面板的 JWT 密钥在每次进程启动时重新生成,重启后旧 Token 失效,重新登录即可。
|
| 191 |
+
|
| 192 |
+
**Q: Docker 挂载卷后配置不生效**
|
| 193 |
+
|
| 194 |
+
确认挂载路径正确,配置文件应在宿主机的 `./config/setting.toml`:
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
docker run -d -p 8081:8081 \
|
| 198 |
+
-v ./config:/app/config \
|
| 199 |
+
-v ./data:/app/data \
|
| 200 |
+
ob12api
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
### 启动报错
|
| 204 |
+
|
| 205 |
+
**Q: 启动时报 `FileNotFoundError` 或 `KeyError`**
|
| 206 |
+
|
| 207 |
+
缺少配置文件或配置项不完整。确保 `config/setting.toml` 存在且包含必要字段(`[global]`、`[server]`、`[ob1]`)。可参考上方 [配置说明](#配置说明)。
|
| 208 |
+
|
| 209 |
+
**Q: 启动时报 `JSONDecodeError`**
|
| 210 |
+
|
| 211 |
+
`config/accounts.json` 或 `data/tokens.json` 文件损坏。删除对应文件后重启,系统会自动重建:
|
| 212 |
+
|
| 213 |
+
```bash
|
| 214 |
+
rm config/accounts.json data/tokens.json
|
| 215 |
+
python main.py
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
### 账号与 Token
|
| 219 |
+
|
| 220 |
+
**Q: 所有请求返回 503 `No valid OB-1 token`**
|
| 221 |
+
|
| 222 |
+
所有账号的 Token 均已过期且自动刷新失败。进入管理面板检查账号状态,尝试重新授权或删除失效账号重新添加。
|
| 223 |
+
|
| 224 |
+
**Q: 设备授权时 WorkOS 返回错误**
|
| 225 |
+
|
| 226 |
+
- 检查网络连通性,确认能访问 `api.workos.com`
|
| 227 |
+
- 如使用代理,确认代理配置正确且代理服务正常运行
|
| 228 |
+
- 在管理面板的「代理设置」中可测试连通性
|
| 229 |
+
|
| 230 |
+
**Q: 调用 API 返回 401 Unauthorized**
|
| 231 |
+
|
| 232 |
+
- 检查请求头中的 API Key 是否正确:`Authorization: Bearer your-api-key`
|
| 233 |
+
- Anthropic 格式也支持 `x-api-key` 头
|
| 234 |
+
- 确认 `config/setting.toml` 中的 `api_key` 或管理面板中已添加对应的 Key
|
| 235 |
+
|
| 236 |
+
### 代理相关
|
| 237 |
+
|
| 238 |
+
**Q: 配置代理后仍然连接超时**
|
| 239 |
+
|
| 240 |
+
确认代理地址格式正确(需包含协议):`http://127.0.0.1:7890`,不要写成 `127.0.0.1:7890`。可在管理面板「代理设置」中点击测试按钮验证。
|
| 241 |
+
|
| 242 |
+
## 环境要求
|
| 243 |
+
|
| 244 |
+
- Python >= 3.11
|
| 245 |
+
- 依赖:FastAPI, uvicorn, httpx, PyJWT, tomli_w
|
| 246 |
+
|
| 247 |
+
## Star History
|
| 248 |
+
|
| 249 |
+
[](https://star-history.com/#longnghiemduc6-art/ob12api&Date)
|
| 250 |
+
|
| 251 |
+
## 免责声明
|
| 252 |
+
|
| 253 |
+
**本项目仅供学习和研究用途,不得用于商业目的。使用者应遵守相关服务条款和法律法规,因使用本项目产生的任何后果由使用者自行承担。**
|
| 254 |
+
|
| 255 |
+
## License
|
| 256 |
+
|
| 257 |
+
MIT
|
config/setting.toml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[global]
|
| 2 |
+
api_key = "your-api-key"
|
| 3 |
+
|
| 4 |
+
[server]
|
| 5 |
+
host = "0.0.0.0"
|
| 6 |
+
port = 8081
|
| 7 |
+
|
| 8 |
+
[admin]
|
| 9 |
+
username = "admin"
|
| 10 |
+
password = "admin"
|
| 11 |
+
|
| 12 |
+
[proxy]
|
| 13 |
+
url = ""
|
| 14 |
+
|
| 15 |
+
[retry]
|
| 16 |
+
max_retries = 3
|
| 17 |
+
retry_delay = 1
|
| 18 |
+
|
| 19 |
+
[ob1]
|
| 20 |
+
credentials_path = ""
|
| 21 |
+
workos_auth_url = "https://api.workos.com/user_management/authenticate"
|
| 22 |
+
workos_client_id = "client_01K8YDZSSKDMK8GYTEHBAW4N4S"
|
| 23 |
+
api_base = "https://dashboard.openblocklabs.com/api/v1"
|
| 24 |
+
refresh_buffer_seconds = 600
|
| 25 |
+
rotation_mode = "balanced"
|
| 26 |
+
refresh_interval = 60
|
| 27 |
+
|
| 28 |
+
[logging]
|
| 29 |
+
level = "INFO"
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
services:
|
| 3 |
+
ob12api:
|
| 4 |
+
build: .
|
| 5 |
+
ports:
|
| 6 |
+
- "8081:8081"
|
| 7 |
+
volumes:
|
| 8 |
+
- ./config:/app/config
|
| 9 |
+
- ./data:/app/data
|
| 10 |
+
restart: unless-stopped
|
main.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OB1 2API launcher."""
|
| 2 |
+
import uvicorn
|
| 3 |
+
|
| 4 |
+
if __name__ == "__main__":
|
| 5 |
+
uvicorn.run("src.main:app", host="0.0.0.0", port=8081, reload=True)
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.100.0
|
| 2 |
+
uvicorn[standard]>=0.23.0
|
| 3 |
+
httpx>=0.25.0
|
| 4 |
+
tomli_w>=1.0.0
|
| 5 |
+
PyJWT>=2.8.0
|
src/__init__.py
ADDED
|
File without changes
|
src/api/__init__.py
ADDED
|
File without changes
|
src/api/admin.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin routes — login, accounts, device auth, settings, keys."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import httpx
|
| 6 |
+
from fastapi import APIRouter, Depends, Request
|
| 7 |
+
from fastapi.responses import JSONResponse
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from ..core.auth import verify_api_key, verify_login, create_login_token
|
| 11 |
+
from ..core import config
|
| 12 |
+
from ..core.config import update_setting
|
| 13 |
+
from ..core.logger import get_logger, set_level
|
| 14 |
+
from ..services.token_manager import OB1TokenManager, DEVICE_AUTH_URL, OB1_WORKOS_AUTH_URL
|
| 15 |
+
from ..services.api_key_manager import ApiKeyManager
|
| 16 |
+
|
| 17 |
+
log = get_logger("admin")
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/admin")
|
| 20 |
+
# Public login route (no auth)
|
| 21 |
+
login_router = APIRouter()
|
| 22 |
+
|
| 23 |
+
_tm: OB1TokenManager = None
|
| 24 |
+
_km: ApiKeyManager = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def init(token_manager: OB1TokenManager, key_manager: ApiKeyManager):
|
| 28 |
+
global _tm, _km
|
| 29 |
+
_tm = token_manager
|
| 30 |
+
_km = key_manager
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ===== Login (public) =====
|
| 34 |
+
|
| 35 |
+
class LoginRequest(BaseModel):
|
| 36 |
+
username: str
|
| 37 |
+
password: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@login_router.post("/api/login")
|
| 41 |
+
async def login(req: LoginRequest):
|
| 42 |
+
if verify_login(req.username, req.password):
|
| 43 |
+
token = create_login_token(req.username)
|
| 44 |
+
return {"success": True, "token": token}
|
| 45 |
+
return JSONResponse(status_code=401, content={"success": False, "message": "用户名或密码错误"})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ===== Protected routes =====
|
| 49 |
+
_auth = [Depends(verify_api_key)]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("/status", dependencies=_auth)
|
| 53 |
+
async def status():
|
| 54 |
+
return {"loaded": _tm.is_loaded, "user": _tm.user_email, "org": _tm.org_id, "current_idx": _tm.current_idx, **_tm.stats}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ===== Accounts =====
|
| 58 |
+
|
| 59 |
+
@router.get("/accounts", dependencies=_auth)
|
| 60 |
+
async def list_accounts():
|
| 61 |
+
return {"accounts": _tm.list_accounts(), "stats": _tm.stats}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.post("/accounts/{idx}/refresh", dependencies=_auth)
|
| 65 |
+
async def refresh_account(idx: int):
|
| 66 |
+
ok = await _tm.refresh_account(idx)
|
| 67 |
+
return {"ok": ok, "error": "" if ok else "refresh failed"}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.delete("/accounts/{idx}", dependencies=_auth)
|
| 71 |
+
async def remove_account(idx: int):
|
| 72 |
+
ok = _tm.remove_account(idx)
|
| 73 |
+
return {"ok": ok}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.post("/refresh", dependencies=_auth)
|
| 77 |
+
async def force_refresh():
|
| 78 |
+
ok = await _tm.refresh()
|
| 79 |
+
return {"ok": ok}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.post("/accounts/export", dependencies=_auth)
|
| 83 |
+
async def export_accounts():
|
| 84 |
+
return {"accounts": [a.to_dict() for a in _tm._accounts]}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class ImportRequest(BaseModel):
|
| 88 |
+
accounts: list[dict]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post("/accounts/import", dependencies=_auth)
|
| 92 |
+
async def import_accounts(req: ImportRequest):
|
| 93 |
+
count = _tm.import_accounts(req.accounts)
|
| 94 |
+
return {"ok": True, "imported": count}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class PushRequest(BaseModel):
|
| 98 |
+
refresh_tokens: list[str] | None = None
|
| 99 |
+
accounts: list[dict] | None = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _verify_api_key_from_request(request: Request) -> bool:
|
| 103 |
+
from ..core.auth import _extract_token
|
| 104 |
+
token = _extract_token(request)
|
| 105 |
+
return bool(token and _km and _km.validate(token))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@router.post("/accounts/push")
|
| 109 |
+
async def push_accounts(req: PushRequest, request: Request):
|
| 110 |
+
"""外部推送账号接口(API Key 鉴权),支持两种模式:
|
| 111 |
+
1. refresh_tokens: 只传 RT,服务端自动刷新补全
|
| 112 |
+
2. accounts: 传完整账号数据,直接导入
|
| 113 |
+
"""
|
| 114 |
+
if not _verify_api_key_from_request(request):
|
| 115 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "Invalid API Key"})
|
| 116 |
+
|
| 117 |
+
imported = 0
|
| 118 |
+
errors = []
|
| 119 |
+
|
| 120 |
+
# 模式1:完整账号数据直接导入
|
| 121 |
+
if req.accounts:
|
| 122 |
+
imported = _tm.import_accounts(req.accounts)
|
| 123 |
+
|
| 124 |
+
# 模式2:只传 refresh_token,自动刷新
|
| 125 |
+
if req.refresh_tokens:
|
| 126 |
+
from ..services.token_manager import Account
|
| 127 |
+
for rt in req.refresh_tokens:
|
| 128 |
+
rt = rt.strip()
|
| 129 |
+
if not rt:
|
| 130 |
+
continue
|
| 131 |
+
existing_rts = {a.refresh_token for a in _tm._accounts}
|
| 132 |
+
if rt in existing_rts:
|
| 133 |
+
errors.append({"refresh_token": rt[:20] + "...", "error": "已存在"})
|
| 134 |
+
continue
|
| 135 |
+
acct = Account({"refresh_token": rt, "expires_at": 0})
|
| 136 |
+
_tm._accounts.append(acct)
|
| 137 |
+
idx = len(_tm._accounts) - 1
|
| 138 |
+
ok = await _tm.refresh_account(idx, force=True)
|
| 139 |
+
if ok:
|
| 140 |
+
imported += 1
|
| 141 |
+
else:
|
| 142 |
+
_tm._accounts.pop(idx)
|
| 143 |
+
errors.append({"refresh_token": rt[:20] + "...", "error": "刷新失败"})
|
| 144 |
+
if imported:
|
| 145 |
+
_tm._save()
|
| 146 |
+
|
| 147 |
+
return {"ok": True, "imported": imported, "errors": errors}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class BatchDeleteRequest(BaseModel):
|
| 151 |
+
indices: list[int]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@router.post("/accounts/batch-delete", dependencies=_auth)
|
| 155 |
+
async def batch_delete_accounts(req: BatchDeleteRequest):
|
| 156 |
+
removed = _tm.batch_remove(req.indices)
|
| 157 |
+
return {"ok": True, "removed": removed}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ===== Device Auth =====
|
| 161 |
+
|
| 162 |
+
@router.post("/device-auth", dependencies=_auth)
|
| 163 |
+
async def start_device_auth():
|
| 164 |
+
try:
|
| 165 |
+
proxy = config.PROXY_URL or None
|
| 166 |
+
async with httpx.AsyncClient(proxy=proxy, timeout=15) as client:
|
| 167 |
+
resp = await client.post(
|
| 168 |
+
DEVICE_AUTH_URL,
|
| 169 |
+
data={"client_id": config.OB1_WORKOS_CLIENT_ID},
|
| 170 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 171 |
+
)
|
| 172 |
+
if resp.status_code != 200:
|
| 173 |
+
return {"error": f"WorkOS returned {resp.status_code}"}
|
| 174 |
+
return resp.json()
|
| 175 |
+
except Exception as e:
|
| 176 |
+
return {"error": str(e)}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class PollRequest(BaseModel):
|
| 180 |
+
device_code: str
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@router.post("/device-auth/poll", dependencies=_auth)
|
| 184 |
+
async def poll_device_auth(req: PollRequest):
|
| 185 |
+
try:
|
| 186 |
+
proxy = config.PROXY_URL or None
|
| 187 |
+
async with httpx.AsyncClient(proxy=proxy, timeout=15) as client:
|
| 188 |
+
resp = await client.post(
|
| 189 |
+
OB1_WORKOS_AUTH_URL,
|
| 190 |
+
data={
|
| 191 |
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
| 192 |
+
"device_code": req.device_code,
|
| 193 |
+
"client_id": config.OB1_WORKOS_CLIENT_ID,
|
| 194 |
+
},
|
| 195 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 196 |
+
timeout=15,
|
| 197 |
+
)
|
| 198 |
+
if resp.status_code == 200:
|
| 199 |
+
result = resp.json()
|
| 200 |
+
email = await _tm.add_account_from_device(result)
|
| 201 |
+
return {"status": "complete", "email": email}
|
| 202 |
+
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
| 203 |
+
error = body.get("error", "")
|
| 204 |
+
if error == "authorization_pending":
|
| 205 |
+
return {"status": "pending", "message": "等待用户授权..."}
|
| 206 |
+
if error == "slow_down":
|
| 207 |
+
return {"status": "pending", "message": "请稍候..."}
|
| 208 |
+
if error == "expired_token":
|
| 209 |
+
return {"status": "expired", "message": "授权已过期"}
|
| 210 |
+
return {"status": "error", "message": body.get("error_description", error or f"HTTP {resp.status_code}")}
|
| 211 |
+
except Exception as e:
|
| 212 |
+
return {"status": "error", "message": str(e)}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ===== API Keys =====
|
| 216 |
+
|
| 217 |
+
class CreateKeyRequest(BaseModel):
|
| 218 |
+
name: str = ""
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@router.get("/keys", dependencies=_auth)
|
| 222 |
+
async def list_keys():
|
| 223 |
+
return {"keys": _km.list_keys()}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@router.post("/keys", dependencies=_auth)
|
| 227 |
+
async def create_key(req: CreateKeyRequest):
|
| 228 |
+
key = _km.create_key(req.name)
|
| 229 |
+
return {"ok": True, "key": key}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@router.delete("/keys/{key}", dependencies=_auth)
|
| 233 |
+
async def delete_key(key: str):
|
| 234 |
+
ok = _km.delete_key(key)
|
| 235 |
+
return {"ok": ok}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@router.post("/keys/{key}/toggle", dependencies=_auth)
|
| 239 |
+
async def toggle_key(key: str):
|
| 240 |
+
ok = _km.toggle_key(key)
|
| 241 |
+
return {"ok": ok}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ===== Settings =====
|
| 245 |
+
|
| 246 |
+
@router.get("/settings", dependencies=_auth)
|
| 247 |
+
async def get_settings():
|
| 248 |
+
return {
|
| 249 |
+
"username": config.ADMIN_USERNAME,
|
| 250 |
+
"api_key": _km._keys[0].key if _km and _km._keys else config.API_KEY,
|
| 251 |
+
"proxy_url": config.PROXY_URL,
|
| 252 |
+
"max_retries": config.MAX_RETRIES,
|
| 253 |
+
"retry_delay": config.RETRY_DELAY,
|
| 254 |
+
"rotation_mode": config.OB1_ROTATION_MODE,
|
| 255 |
+
"refresh_interval": config.OB1_REFRESH_INTERVAL,
|
| 256 |
+
"log_level": config.LOG_LEVEL,
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class PasswordUpdate(BaseModel):
|
| 261 |
+
old_password: str
|
| 262 |
+
new_password: str
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@router.post("/settings/password", dependencies=_auth)
|
| 266 |
+
async def update_password(req: PasswordUpdate):
|
| 267 |
+
if req.old_password != config.ADMIN_PASSWORD:
|
| 268 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "旧密码错误"})
|
| 269 |
+
update_setting("admin", "password", req.new_password)
|
| 270 |
+
return {"ok": True}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class UsernameUpdate(BaseModel):
|
| 274 |
+
username: str
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
@router.post("/settings/username", dependencies=_auth)
|
| 278 |
+
async def update_username(req: UsernameUpdate):
|
| 279 |
+
update_setting("admin", "username", req.username)
|
| 280 |
+
return {"ok": True}
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
class ApiKeyUpdate(BaseModel):
|
| 284 |
+
api_key: str
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@router.post("/settings/api-key", dependencies=_auth)
|
| 288 |
+
async def update_api_key_setting(req: ApiKeyUpdate):
|
| 289 |
+
old_key = config.API_KEY
|
| 290 |
+
update_setting("global", "api_key", req.api_key)
|
| 291 |
+
# Sync to ApiKeyManager so the new key is immediately usable
|
| 292 |
+
if _km:
|
| 293 |
+
_km.delete_key(old_key)
|
| 294 |
+
_km.create_key_with_value(req.api_key, "默认密钥")
|
| 295 |
+
return {"ok": True}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
class ProxyUpdate(BaseModel):
|
| 299 |
+
url: str = ""
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@router.post("/settings/proxy", dependencies=_auth)
|
| 303 |
+
async def update_proxy(req: ProxyUpdate):
|
| 304 |
+
update_setting("proxy", "url", req.url)
|
| 305 |
+
return {"ok": True}
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
class ProxyTestRequest(BaseModel):
|
| 309 |
+
url: str = ""
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@router.post("/settings/proxy-test", dependencies=_auth)
|
| 313 |
+
async def test_proxy(req: ProxyTestRequest):
|
| 314 |
+
proxy_url = req.url.strip()
|
| 315 |
+
if not proxy_url:
|
| 316 |
+
return {"ok": False, "error": "代理地址为空"}
|
| 317 |
+
try:
|
| 318 |
+
async with httpx.AsyncClient(proxy=proxy_url, timeout=10) as client:
|
| 319 |
+
resp = await client.get("https://httpbin.org/ip")
|
| 320 |
+
if resp.status_code == 200:
|
| 321 |
+
ip = resp.json().get("origin", "unknown")
|
| 322 |
+
return {"ok": True, "ip": ip}
|
| 323 |
+
return {"ok": False, "error": f"HTTP {resp.status_code}"}
|
| 324 |
+
except Exception as e:
|
| 325 |
+
return {"ok": False, "error": str(e)}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
class RetryUpdate(BaseModel):
|
| 329 |
+
max_retries: int = 3
|
| 330 |
+
retry_delay: int = 1
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@router.post("/settings/retry", dependencies=_auth)
|
| 334 |
+
async def update_retry(req: RetryUpdate):
|
| 335 |
+
update_setting("retry", "max_retries", req.max_retries)
|
| 336 |
+
update_setting("retry", "retry_delay", req.retry_delay)
|
| 337 |
+
return {"ok": True}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
class RotationModeUpdate(BaseModel):
|
| 341 |
+
mode: str
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@router.post("/settings/rotation-mode", dependencies=_auth)
|
| 345 |
+
async def update_rotation_mode(req: RotationModeUpdate):
|
| 346 |
+
if req.mode not in ("cache-first", "balanced", "performance"):
|
| 347 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "无效的调度模式"})
|
| 348 |
+
update_setting("ob1", "rotation_mode", req.mode)
|
| 349 |
+
return {"ok": True}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class LogLevelUpdate(BaseModel):
|
| 353 |
+
level: str
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
@router.post("/settings/log-level", dependencies=_auth)
|
| 357 |
+
async def update_log_level(req: LogLevelUpdate):
|
| 358 |
+
lvl = req.level.upper()
|
| 359 |
+
if lvl not in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
| 360 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "无效的日志级别"})
|
| 361 |
+
update_setting("logging", "level", lvl)
|
| 362 |
+
set_level(lvl)
|
| 363 |
+
return {"ok": True}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
class RefreshIntervalUpdate(BaseModel):
|
| 367 |
+
interval: int = 0
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
@router.post("/settings/refresh-interval", dependencies=_auth)
|
| 371 |
+
async def update_refresh_interval(req: RefreshIntervalUpdate):
|
| 372 |
+
if req.interval < 0:
|
| 373 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "刷新间隔不能为负数"})
|
| 374 |
+
update_setting("ob1", "refresh_interval", req.interval)
|
| 375 |
+
# Restart the periodic refresh task
|
| 376 |
+
from ..main import restart_auto_refresh
|
| 377 |
+
restart_auto_refresh()
|
| 378 |
+
return {"ok": True}
|
src/api/routes.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI-compatible API routes — proxies to OB-1 backend."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
from typing import Any, AsyncGenerator, Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, Request
|
| 11 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 12 |
+
|
| 13 |
+
from ..core.auth import verify_api_key
|
| 14 |
+
from ..core.logger import get_logger
|
| 15 |
+
from ..core.models import AnthropicMessagesRequest, ChatCompletionRequest
|
| 16 |
+
from ..services.ob1_client import OB1Client
|
| 17 |
+
from ..services.token_manager import OB1TokenManager
|
| 18 |
+
|
| 19 |
+
log = get_logger("routes")
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
_token_manager: Optional[OB1TokenManager] = None
|
| 24 |
+
_ob1_client: Optional[OB1Client] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def init(token_manager: OB1TokenManager, ob1_client: OB1Client):
|
| 28 |
+
global _token_manager, _ob1_client
|
| 29 |
+
_token_manager = token_manager
|
| 30 |
+
_ob1_client = ob1_client
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _require_token_manager() -> OB1TokenManager:
|
| 34 |
+
if _token_manager is None:
|
| 35 |
+
raise RuntimeError("Token manager is not initialized")
|
| 36 |
+
return _token_manager
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _require_ob1_client() -> OB1Client:
|
| 40 |
+
if _ob1_client is None:
|
| 41 |
+
raise RuntimeError("OB1 client is not initialized")
|
| 42 |
+
return _ob1_client
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.get("/v1/models")
|
| 46 |
+
async def list_models(_: str = Depends(verify_api_key)):
|
| 47 |
+
token_manager = _require_token_manager()
|
| 48 |
+
ob1_client = _require_ob1_client()
|
| 49 |
+
api_key = await token_manager.get_api_key()
|
| 50 |
+
if not api_key:
|
| 51 |
+
return {"object": "list", "data": []}
|
| 52 |
+
raw = await ob1_client.fetch_models(api_key)
|
| 53 |
+
models = []
|
| 54 |
+
for m in raw:
|
| 55 |
+
models.append(
|
| 56 |
+
{
|
| 57 |
+
"id": m["id"],
|
| 58 |
+
"object": "model",
|
| 59 |
+
"created": m.get("created", 0),
|
| 60 |
+
"owned_by": m["id"].split("/")[0] if "/" in m["id"] else "ob1",
|
| 61 |
+
"name": m.get("name", m["id"]),
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
return {"object": "list", "data": models}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post("/v1/chat/completions")
|
| 68 |
+
async def chat_completions(
|
| 69 |
+
request: ChatCompletionRequest,
|
| 70 |
+
_: str = Depends(verify_api_key),
|
| 71 |
+
):
|
| 72 |
+
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
| 73 |
+
extra_payload = _build_openai_extra_payload(request.tools, request.tool_choice)
|
| 74 |
+
resp = await _send_chat_request(
|
| 75 |
+
messages=messages,
|
| 76 |
+
model=request.model,
|
| 77 |
+
stream=request.stream,
|
| 78 |
+
temperature=request.temperature,
|
| 79 |
+
top_p=request.top_p,
|
| 80 |
+
max_tokens=request.max_tokens,
|
| 81 |
+
extra_payload=extra_payload,
|
| 82 |
+
)
|
| 83 |
+
if isinstance(resp, JSONResponse):
|
| 84 |
+
return resp
|
| 85 |
+
|
| 86 |
+
if request.stream:
|
| 87 |
+
log.debug("Streaming response started")
|
| 88 |
+
return StreamingResponse(
|
| 89 |
+
_proxy_stream(resp, _token_manager),
|
| 90 |
+
media_type="text/event-stream",
|
| 91 |
+
)
|
| 92 |
+
else:
|
| 93 |
+
data = resp.json()
|
| 94 |
+
usage = data.get("usage", {})
|
| 95 |
+
_track_usage(usage)
|
| 96 |
+
log.info(
|
| 97 |
+
"Chat response: model=%s prompt_tokens=%d completion_tokens=%d",
|
| 98 |
+
data.get("model", "?"),
|
| 99 |
+
usage.get("prompt_tokens", 0),
|
| 100 |
+
usage.get("completion_tokens", 0),
|
| 101 |
+
)
|
| 102 |
+
return JSONResponse(content=data)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.post("/v1/messages")
|
| 106 |
+
async def anthropic_messages(
|
| 107 |
+
request: AnthropicMessagesRequest,
|
| 108 |
+
_: str = Depends(verify_api_key),
|
| 109 |
+
):
|
| 110 |
+
messages = _anthropic_to_openai_messages(request)
|
| 111 |
+
extra_payload = _build_openai_extra_payload(
|
| 112 |
+
_anthropic_tools_to_openai(request.tools),
|
| 113 |
+
_anthropic_tool_choice_to_openai(request.tool_choice),
|
| 114 |
+
)
|
| 115 |
+
resp = await _send_chat_request(
|
| 116 |
+
messages=messages,
|
| 117 |
+
model=request.model,
|
| 118 |
+
stream=request.stream,
|
| 119 |
+
temperature=request.temperature,
|
| 120 |
+
top_p=request.top_p,
|
| 121 |
+
max_tokens=request.max_tokens,
|
| 122 |
+
extra_payload=extra_payload,
|
| 123 |
+
)
|
| 124 |
+
if isinstance(resp, JSONResponse):
|
| 125 |
+
return resp
|
| 126 |
+
|
| 127 |
+
if request.stream:
|
| 128 |
+
return StreamingResponse(
|
| 129 |
+
_proxy_stream_anthropic(resp, request.model),
|
| 130 |
+
media_type="text/event-stream",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
data = resp.json()
|
| 134 |
+
usage = data.get("usage", {})
|
| 135 |
+
_track_usage(usage)
|
| 136 |
+
await resp.aclose()
|
| 137 |
+
return JSONResponse(content=_openai_to_anthropic_response(data, request.model))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def _send_chat_request(
|
| 141 |
+
*,
|
| 142 |
+
messages: list[dict[str, Any]],
|
| 143 |
+
model: str,
|
| 144 |
+
stream: bool,
|
| 145 |
+
temperature: float | None,
|
| 146 |
+
top_p: float | None,
|
| 147 |
+
max_tokens: int | None,
|
| 148 |
+
extra_payload: dict[str, Any] | None = None,
|
| 149 |
+
):
|
| 150 |
+
token_manager = _require_token_manager()
|
| 151 |
+
ob1_client = _require_ob1_client()
|
| 152 |
+
api_key = await token_manager.get_api_key()
|
| 153 |
+
if not api_key:
|
| 154 |
+
log.warning("No valid OB-1 token available")
|
| 155 |
+
return JSONResponse(
|
| 156 |
+
status_code=503,
|
| 157 |
+
content={"error": "No valid OB-1 token. Run ob1 auth to login."},
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
resolved_model = await _resolve_model_name(model, api_key)
|
| 161 |
+
|
| 162 |
+
log.info(
|
| 163 |
+
"Chat request: model=%s resolved_model=%s stream=%s messages=%d",
|
| 164 |
+
model,
|
| 165 |
+
resolved_model,
|
| 166 |
+
stream,
|
| 167 |
+
len(messages),
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
resp = await ob1_client.chat(
|
| 172 |
+
api_key=api_key,
|
| 173 |
+
messages=messages,
|
| 174 |
+
model=resolved_model,
|
| 175 |
+
stream=stream,
|
| 176 |
+
temperature=temperature,
|
| 177 |
+
top_p=top_p,
|
| 178 |
+
max_tokens=max_tokens,
|
| 179 |
+
extra_payload=extra_payload,
|
| 180 |
+
)
|
| 181 |
+
except Exception as e:
|
| 182 |
+
log.error("Backend error: %s", e)
|
| 183 |
+
return JSONResponse(status_code=502, content={"error": f"Backend error: {e}"})
|
| 184 |
+
|
| 185 |
+
if resp.status_code == 401:
|
| 186 |
+
await resp.aclose()
|
| 187 |
+
log.warning("Token rejected (401), refreshing...")
|
| 188 |
+
ok = await token_manager.refresh()
|
| 189 |
+
if not ok:
|
| 190 |
+
log.error("Token refresh failed")
|
| 191 |
+
return JSONResponse(
|
| 192 |
+
status_code=401, content={"error": "Token expired and refresh failed"}
|
| 193 |
+
)
|
| 194 |
+
api_key = await token_manager.get_api_key()
|
| 195 |
+
if not api_key:
|
| 196 |
+
return JSONResponse(
|
| 197 |
+
status_code=401, content={"error": "Token refresh failed"}
|
| 198 |
+
)
|
| 199 |
+
try:
|
| 200 |
+
resp = await ob1_client.chat(
|
| 201 |
+
api_key=api_key,
|
| 202 |
+
messages=messages,
|
| 203 |
+
model=resolved_model,
|
| 204 |
+
stream=stream,
|
| 205 |
+
temperature=temperature,
|
| 206 |
+
top_p=top_p,
|
| 207 |
+
max_tokens=max_tokens,
|
| 208 |
+
extra_payload=extra_payload,
|
| 209 |
+
)
|
| 210 |
+
except Exception as e:
|
| 211 |
+
log.error("Backend error after refresh: %s", e)
|
| 212 |
+
return JSONResponse(
|
| 213 |
+
status_code=502, content={"error": f"Backend error: {e}"}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if resp.status_code != 200:
|
| 217 |
+
try:
|
| 218 |
+
body = (await resp.aread()).decode()
|
| 219 |
+
except Exception:
|
| 220 |
+
body = "unable to read response body"
|
| 221 |
+
await resp.aclose()
|
| 222 |
+
log.error("OB-1 returned %d: %s", resp.status_code, body[:200])
|
| 223 |
+
return JSONResponse(
|
| 224 |
+
status_code=resp.status_code,
|
| 225 |
+
content={"error": f"OB-1 returned {resp.status_code}: {body[:500]}"},
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
return resp
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
async def _resolve_model_name(requested_model: str, api_key: str) -> str:
|
| 232 |
+
ob1_client = _require_ob1_client()
|
| 233 |
+
raw_models = await ob1_client.fetch_models(api_key)
|
| 234 |
+
available = [item.get("id") for item in raw_models if item.get("id")]
|
| 235 |
+
if requested_model in available:
|
| 236 |
+
return requested_model
|
| 237 |
+
|
| 238 |
+
anthropic_prefixed = f"anthropic/{requested_model}"
|
| 239 |
+
if anthropic_prefixed in available:
|
| 240 |
+
return anthropic_prefixed
|
| 241 |
+
|
| 242 |
+
if requested_model.startswith("claude-"):
|
| 243 |
+
lowered = requested_model.lower()
|
| 244 |
+
family = None
|
| 245 |
+
for candidate in ("haiku", "sonnet", "opus"):
|
| 246 |
+
if candidate in lowered:
|
| 247 |
+
family = candidate
|
| 248 |
+
break
|
| 249 |
+
|
| 250 |
+
if family:
|
| 251 |
+
family_matches = [
|
| 252 |
+
model_id
|
| 253 |
+
for model_id in available
|
| 254 |
+
if model_id.startswith(f"anthropic/claude-{family}")
|
| 255 |
+
]
|
| 256 |
+
if family_matches:
|
| 257 |
+
return sorted(family_matches)[-1]
|
| 258 |
+
|
| 259 |
+
anthropic_models = [
|
| 260 |
+
model_id for model_id in available if model_id.startswith("anthropic/")
|
| 261 |
+
]
|
| 262 |
+
preferred_order = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.6"]
|
| 263 |
+
for model_id in preferred_order:
|
| 264 |
+
if model_id in anthropic_models:
|
| 265 |
+
return model_id
|
| 266 |
+
if anthropic_models:
|
| 267 |
+
return sorted(anthropic_models)[-1]
|
| 268 |
+
|
| 269 |
+
return requested_model
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _anthropic_to_openai_messages(
|
| 273 |
+
request: AnthropicMessagesRequest,
|
| 274 |
+
) -> list[dict[str, Any]]:
|
| 275 |
+
messages: list[dict[str, Any]] = []
|
| 276 |
+
if request.system:
|
| 277 |
+
messages.append({"role": "system", "content": _flatten_content(request.system)})
|
| 278 |
+
for message in request.messages:
|
| 279 |
+
blocks = (
|
| 280 |
+
message.content
|
| 281 |
+
if isinstance(message.content, list)
|
| 282 |
+
else [{"type": "text", "text": message.content}]
|
| 283 |
+
)
|
| 284 |
+
text_parts: list[str] = []
|
| 285 |
+
tool_calls: list[dict[str, Any]] = []
|
| 286 |
+
tool_results: list[dict[str, Any]] = []
|
| 287 |
+
|
| 288 |
+
for block in blocks:
|
| 289 |
+
if not isinstance(block, dict):
|
| 290 |
+
continue
|
| 291 |
+
block_type = block.get("type")
|
| 292 |
+
if block_type == "text" and isinstance(block.get("text"), str):
|
| 293 |
+
text_parts.append(block["text"])
|
| 294 |
+
elif block_type == "tool_use":
|
| 295 |
+
tool_calls.append(
|
| 296 |
+
{
|
| 297 |
+
"id": block.get("id") or f"call_{uuid.uuid4().hex}",
|
| 298 |
+
"type": "function",
|
| 299 |
+
"function": {
|
| 300 |
+
"name": block.get("name", "tool"),
|
| 301 |
+
"arguments": json.dumps(
|
| 302 |
+
block.get("input", {}), ensure_ascii=False
|
| 303 |
+
),
|
| 304 |
+
},
|
| 305 |
+
}
|
| 306 |
+
)
|
| 307 |
+
elif block_type == "tool_result":
|
| 308 |
+
tool_results.append(
|
| 309 |
+
{
|
| 310 |
+
"role": "tool",
|
| 311 |
+
"tool_call_id": block.get("tool_use_id", ""),
|
| 312 |
+
"content": _flatten_content(block.get("content", "")),
|
| 313 |
+
}
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
if message.role == "assistant":
|
| 317 |
+
assistant_message: dict[str, Any] = {"role": "assistant"}
|
| 318 |
+
assistant_message["content"] = "\n".join(
|
| 319 |
+
part for part in text_parts if part
|
| 320 |
+
)
|
| 321 |
+
if tool_calls:
|
| 322 |
+
assistant_message["tool_calls"] = tool_calls
|
| 323 |
+
messages.append(assistant_message)
|
| 324 |
+
elif message.role == "user" and tool_results:
|
| 325 |
+
messages.extend(tool_results)
|
| 326 |
+
if text_parts:
|
| 327 |
+
messages.append({"role": "user", "content": "\n".join(text_parts)})
|
| 328 |
+
else:
|
| 329 |
+
messages.append(
|
| 330 |
+
{
|
| 331 |
+
"role": message.role,
|
| 332 |
+
"content": "\n".join(part for part in text_parts if part),
|
| 333 |
+
}
|
| 334 |
+
)
|
| 335 |
+
return messages
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _anthropic_tools_to_openai(
|
| 339 |
+
tools: Optional[list[dict[str, Any]]],
|
| 340 |
+
) -> Optional[list[dict[str, Any]]]:
|
| 341 |
+
if not tools:
|
| 342 |
+
return None
|
| 343 |
+
converted: list[dict[str, Any]] = []
|
| 344 |
+
for tool in tools:
|
| 345 |
+
converted.append(
|
| 346 |
+
{
|
| 347 |
+
"type": "function",
|
| 348 |
+
"function": {
|
| 349 |
+
"name": tool.get("name", "tool"),
|
| 350 |
+
"description": tool.get("description", ""),
|
| 351 |
+
"parameters": tool.get(
|
| 352 |
+
"input_schema", {"type": "object", "properties": {}}
|
| 353 |
+
),
|
| 354 |
+
},
|
| 355 |
+
}
|
| 356 |
+
)
|
| 357 |
+
return converted
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _anthropic_tool_choice_to_openai(
|
| 361 |
+
tool_choice: Optional[dict[str, Any]],
|
| 362 |
+
) -> Optional[dict[str, Any] | str]:
|
| 363 |
+
if not tool_choice:
|
| 364 |
+
return None
|
| 365 |
+
choice_type = tool_choice.get("type")
|
| 366 |
+
if choice_type in {"auto", "none"}:
|
| 367 |
+
return choice_type
|
| 368 |
+
if choice_type in {"any", "required"}:
|
| 369 |
+
return "required"
|
| 370 |
+
if choice_type == "tool":
|
| 371 |
+
name = tool_choice.get("name")
|
| 372 |
+
if name:
|
| 373 |
+
return {"type": "function", "function": {"name": name}}
|
| 374 |
+
return None
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def _build_openai_extra_payload(
|
| 378 |
+
tools: Optional[list[dict[str, Any]]],
|
| 379 |
+
tool_choice: Optional[dict[str, Any] | str],
|
| 380 |
+
) -> Optional[dict[str, Any]]:
|
| 381 |
+
extra_payload: dict[str, Any] = {}
|
| 382 |
+
if tools:
|
| 383 |
+
extra_payload["tools"] = tools
|
| 384 |
+
if tool_choice is not None:
|
| 385 |
+
extra_payload["tool_choice"] = tool_choice
|
| 386 |
+
return extra_payload or None
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _flatten_content(content: Any) -> str:
|
| 390 |
+
if content is None:
|
| 391 |
+
return ""
|
| 392 |
+
if isinstance(content, str):
|
| 393 |
+
return content
|
| 394 |
+
if isinstance(content, list):
|
| 395 |
+
parts: list[str] = []
|
| 396 |
+
for block in content:
|
| 397 |
+
if not isinstance(block, dict):
|
| 398 |
+
continue
|
| 399 |
+
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
| 400 |
+
parts.append(block["text"])
|
| 401 |
+
elif block.get("type") == "tool_result":
|
| 402 |
+
parts.append(_flatten_content(block.get("content", "")))
|
| 403 |
+
return "\n".join(part for part in parts if part)
|
| 404 |
+
return str(content)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _openai_to_anthropic_response(data: dict[str, Any], model: str) -> dict[str, Any]:
|
| 408 |
+
choice = (data.get("choices") or [{}])[0]
|
| 409 |
+
message = choice.get("message") or {}
|
| 410 |
+
content_blocks: list[dict[str, Any]] = []
|
| 411 |
+
text = _flatten_content(message.get("content", ""))
|
| 412 |
+
if text:
|
| 413 |
+
content_blocks.append({"type": "text", "text": text})
|
| 414 |
+
for tool_call in message.get("tool_calls") or []:
|
| 415 |
+
function = tool_call.get("function") or {}
|
| 416 |
+
content_blocks.append(
|
| 417 |
+
{
|
| 418 |
+
"type": "tool_use",
|
| 419 |
+
"id": tool_call.get("id", f"toolu_{uuid.uuid4().hex}"),
|
| 420 |
+
"name": function.get("name", "tool"),
|
| 421 |
+
"input": _parse_json_object(function.get("arguments")),
|
| 422 |
+
}
|
| 423 |
+
)
|
| 424 |
+
usage = data.get("usage") or {}
|
| 425 |
+
return {
|
| 426 |
+
"id": data.get("id", f"msg_{uuid.uuid4().hex}"),
|
| 427 |
+
"type": "message",
|
| 428 |
+
"role": "assistant",
|
| 429 |
+
"content": content_blocks or [{"type": "text", "text": ""}],
|
| 430 |
+
"model": data.get("model", model),
|
| 431 |
+
"stop_reason": _map_finish_reason(choice.get("finish_reason")),
|
| 432 |
+
"stop_sequence": None,
|
| 433 |
+
"usage": {
|
| 434 |
+
"input_tokens": usage.get("prompt_tokens", 0),
|
| 435 |
+
"output_tokens": usage.get("completion_tokens", 0),
|
| 436 |
+
},
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _map_finish_reason(reason: Optional[str]) -> str:
|
| 441 |
+
if reason in {None, "stop"}:
|
| 442 |
+
return "end_turn"
|
| 443 |
+
if reason == "length":
|
| 444 |
+
return "max_tokens"
|
| 445 |
+
if reason == "tool_calls":
|
| 446 |
+
return "tool_use"
|
| 447 |
+
return reason or "end_turn"
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _parse_json_object(value: Any) -> dict[str, Any]:
|
| 451 |
+
if isinstance(value, dict):
|
| 452 |
+
return value
|
| 453 |
+
if isinstance(value, str) and value:
|
| 454 |
+
try:
|
| 455 |
+
parsed = json.loads(value)
|
| 456 |
+
if isinstance(parsed, dict):
|
| 457 |
+
return parsed
|
| 458 |
+
except json.JSONDecodeError:
|
| 459 |
+
pass
|
| 460 |
+
return {}
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
async def _proxy_stream_anthropic(resp, model: str) -> AsyncGenerator[str, None]:
|
| 464 |
+
message_id = f"msg_{uuid.uuid4().hex}"
|
| 465 |
+
sent_start = False
|
| 466 |
+
text_started = False
|
| 467 |
+
text_index = 0
|
| 468 |
+
usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0}
|
| 469 |
+
stop_reason = "end_turn"
|
| 470 |
+
tool_state: dict[int, dict[str, Any]] = {}
|
| 471 |
+
next_content_index = 0
|
| 472 |
+
try:
|
| 473 |
+
async for line in resp.aiter_lines():
|
| 474 |
+
if not line or not line.startswith("data: "):
|
| 475 |
+
continue
|
| 476 |
+
payload = line[6:]
|
| 477 |
+
if payload == "[DONE]":
|
| 478 |
+
break
|
| 479 |
+
try:
|
| 480 |
+
chunk = json.loads(payload)
|
| 481 |
+
except json.JSONDecodeError:
|
| 482 |
+
continue
|
| 483 |
+
|
| 484 |
+
if not sent_start:
|
| 485 |
+
prompt_tokens = ((chunk.get("usage") or {}).get("prompt_tokens")) or 0
|
| 486 |
+
usage["input_tokens"] = prompt_tokens
|
| 487 |
+
yield _anthropic_sse(
|
| 488 |
+
"message_start",
|
| 489 |
+
{
|
| 490 |
+
"type": "message_start",
|
| 491 |
+
"message": {
|
| 492 |
+
"id": message_id,
|
| 493 |
+
"type": "message",
|
| 494 |
+
"role": "assistant",
|
| 495 |
+
"content": [],
|
| 496 |
+
"model": chunk.get("model", model),
|
| 497 |
+
"stop_reason": None,
|
| 498 |
+
"stop_sequence": None,
|
| 499 |
+
"usage": usage,
|
| 500 |
+
},
|
| 501 |
+
},
|
| 502 |
+
)
|
| 503 |
+
sent_start = True
|
| 504 |
+
|
| 505 |
+
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
|
| 506 |
+
finish_reason = (chunk.get("choices") or [{}])[0].get("finish_reason")
|
| 507 |
+
text = delta.get("content")
|
| 508 |
+
if text:
|
| 509 |
+
if not text_started:
|
| 510 |
+
text_index = next_content_index
|
| 511 |
+
yield _anthropic_sse(
|
| 512 |
+
"content_block_start",
|
| 513 |
+
{
|
| 514 |
+
"type": "content_block_start",
|
| 515 |
+
"index": text_index,
|
| 516 |
+
"content_block": {"type": "text", "text": ""},
|
| 517 |
+
},
|
| 518 |
+
)
|
| 519 |
+
text_started = True
|
| 520 |
+
next_content_index += 1
|
| 521 |
+
yield _anthropic_sse(
|
| 522 |
+
"content_block_delta",
|
| 523 |
+
{
|
| 524 |
+
"type": "content_block_delta",
|
| 525 |
+
"index": text_index,
|
| 526 |
+
"delta": {"type": "text_delta", "text": text},
|
| 527 |
+
},
|
| 528 |
+
)
|
| 529 |
+
chunk_usage = chunk.get("usage") or {}
|
| 530 |
+
if chunk_usage.get("completion_tokens") is not None:
|
| 531 |
+
usage["output_tokens"] = chunk_usage.get(
|
| 532 |
+
"completion_tokens", usage["output_tokens"]
|
| 533 |
+
)
|
| 534 |
+
_track_usage(chunk_usage)
|
| 535 |
+
for tool_delta in delta.get("tool_calls") or []:
|
| 536 |
+
tool_idx = tool_delta.get("index", 0)
|
| 537 |
+
state = tool_state.setdefault(
|
| 538 |
+
tool_idx,
|
| 539 |
+
{
|
| 540 |
+
"event_index": next_content_index,
|
| 541 |
+
"id": tool_delta.get("id") or f"toolu_{uuid.uuid4().hex}",
|
| 542 |
+
"name": ((tool_delta.get("function") or {}).get("name"))
|
| 543 |
+
or "tool",
|
| 544 |
+
"arguments": "",
|
| 545 |
+
"started": False,
|
| 546 |
+
},
|
| 547 |
+
)
|
| 548 |
+
if tool_delta.get("id"):
|
| 549 |
+
state["id"] = tool_delta["id"]
|
| 550 |
+
function = tool_delta.get("function") or {}
|
| 551 |
+
if function.get("name"):
|
| 552 |
+
state["name"] = function["name"]
|
| 553 |
+
if not state["started"]:
|
| 554 |
+
next_content_index = max(
|
| 555 |
+
next_content_index, state["event_index"] + 1
|
| 556 |
+
)
|
| 557 |
+
yield _anthropic_sse(
|
| 558 |
+
"content_block_start",
|
| 559 |
+
{
|
| 560 |
+
"type": "content_block_start",
|
| 561 |
+
"index": state["event_index"],
|
| 562 |
+
"content_block": {
|
| 563 |
+
"type": "tool_use",
|
| 564 |
+
"id": state["id"],
|
| 565 |
+
"name": state["name"],
|
| 566 |
+
"input": {},
|
| 567 |
+
},
|
| 568 |
+
},
|
| 569 |
+
)
|
| 570 |
+
state["started"] = True
|
| 571 |
+
if function.get("arguments"):
|
| 572 |
+
state["arguments"] += function["arguments"]
|
| 573 |
+
yield _anthropic_sse(
|
| 574 |
+
"content_block_delta",
|
| 575 |
+
{
|
| 576 |
+
"type": "content_block_delta",
|
| 577 |
+
"index": state["event_index"],
|
| 578 |
+
"delta": {
|
| 579 |
+
"type": "input_json_delta",
|
| 580 |
+
"partial_json": function["arguments"],
|
| 581 |
+
},
|
| 582 |
+
},
|
| 583 |
+
)
|
| 584 |
+
if finish_reason:
|
| 585 |
+
stop_reason = _map_finish_reason(finish_reason)
|
| 586 |
+
|
| 587 |
+
if not sent_start:
|
| 588 |
+
yield _anthropic_sse(
|
| 589 |
+
"message_start",
|
| 590 |
+
{
|
| 591 |
+
"type": "message_start",
|
| 592 |
+
"message": {
|
| 593 |
+
"id": message_id,
|
| 594 |
+
"type": "message",
|
| 595 |
+
"role": "assistant",
|
| 596 |
+
"content": [],
|
| 597 |
+
"model": model,
|
| 598 |
+
"stop_reason": None,
|
| 599 |
+
"stop_sequence": None,
|
| 600 |
+
"usage": usage,
|
| 601 |
+
},
|
| 602 |
+
},
|
| 603 |
+
)
|
| 604 |
+
if text_started:
|
| 605 |
+
yield _anthropic_sse(
|
| 606 |
+
"content_block_stop",
|
| 607 |
+
{"type": "content_block_stop", "index": text_index},
|
| 608 |
+
)
|
| 609 |
+
elif not tool_state:
|
| 610 |
+
yield _anthropic_sse(
|
| 611 |
+
"content_block_start",
|
| 612 |
+
{
|
| 613 |
+
"type": "content_block_start",
|
| 614 |
+
"index": next_content_index,
|
| 615 |
+
"content_block": {"type": "text", "text": ""},
|
| 616 |
+
},
|
| 617 |
+
)
|
| 618 |
+
yield _anthropic_sse(
|
| 619 |
+
"content_block_stop",
|
| 620 |
+
{"type": "content_block_stop", "index": next_content_index},
|
| 621 |
+
)
|
| 622 |
+
for state in sorted(tool_state.values(), key=lambda item: item["event_index"]):
|
| 623 |
+
if state["started"]:
|
| 624 |
+
yield _anthropic_sse(
|
| 625 |
+
"content_block_stop",
|
| 626 |
+
{"type": "content_block_stop", "index": state["event_index"]},
|
| 627 |
+
)
|
| 628 |
+
yield _anthropic_sse(
|
| 629 |
+
"message_delta",
|
| 630 |
+
{
|
| 631 |
+
"type": "message_delta",
|
| 632 |
+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
| 633 |
+
"usage": {"output_tokens": usage["output_tokens"]},
|
| 634 |
+
},
|
| 635 |
+
)
|
| 636 |
+
yield _anthropic_sse("message_stop", {"type": "message_stop"})
|
| 637 |
+
finally:
|
| 638 |
+
await resp.aclose()
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
def _anthropic_sse(event: str, data: dict[str, Any]) -> str:
|
| 642 |
+
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def _track_usage(usage: dict):
|
| 646 |
+
"""Extract token counts from usage and record cost."""
|
| 647 |
+
pt = usage.get("prompt_tokens", 0)
|
| 648 |
+
ct = usage.get("completion_tokens", 0)
|
| 649 |
+
if pt or ct:
|
| 650 |
+
# Rough OpenRouter-style cost estimate (per 1M tokens)
|
| 651 |
+
cost = pt * 0.000015 + ct * 0.000075
|
| 652 |
+
_require_token_manager().add_cost(cost)
|
| 653 |
+
elif usage:
|
| 654 |
+
_require_token_manager().add_cost(0)
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
async def _proxy_stream(resp, tm) -> AsyncGenerator[str, None]:
|
| 658 |
+
"""Proxy SSE stream from OB-1 backend directly to client."""
|
| 659 |
+
try:
|
| 660 |
+
async for line in resp.aiter_lines():
|
| 661 |
+
if line:
|
| 662 |
+
yield f"{line}\n\n"
|
| 663 |
+
# Extract usage from the final chunk
|
| 664 |
+
if line.startswith("data: ") and '"usage"' in line:
|
| 665 |
+
try:
|
| 666 |
+
chunk = json.loads(line[6:])
|
| 667 |
+
usage = chunk.get("usage") or {}
|
| 668 |
+
if usage:
|
| 669 |
+
_track_usage(usage)
|
| 670 |
+
except Exception:
|
| 671 |
+
pass
|
| 672 |
+
finally:
|
| 673 |
+
await resp.aclose()
|
src/core/__init__.py
ADDED
|
File without changes
|
src/core/auth.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API key + JWT verification."""
|
| 2 |
+
|
| 3 |
+
import secrets
|
| 4 |
+
import time
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import jwt
|
| 8 |
+
from fastapi import HTTPException, Request
|
| 9 |
+
|
| 10 |
+
from ..services.api_key_manager import ApiKeyManager
|
| 11 |
+
from ..core import config
|
| 12 |
+
|
| 13 |
+
_key_manager: Optional[ApiKeyManager] = None
|
| 14 |
+
_JWT_SECRET = secrets.token_hex(32)
|
| 15 |
+
_JWT_EXPIRE = 86400 * 7 # 7 days
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def init_auth(key_manager: ApiKeyManager):
|
| 19 |
+
global _key_manager
|
| 20 |
+
_key_manager = key_manager
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def create_login_token(username: str) -> str:
|
| 24 |
+
payload = {"sub": username, "exp": int(time.time()) + _JWT_EXPIRE}
|
| 25 |
+
return jwt.encode(payload, _JWT_SECRET, algorithm="HS256")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def verify_login(username: str, password: str) -> bool:
|
| 29 |
+
return username == config.ADMIN_USERNAME and password == config.ADMIN_PASSWORD
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _extract_token(request: Request) -> Optional[str]:
|
| 33 |
+
auth_header = request.headers.get("authorization", "")
|
| 34 |
+
if auth_header.lower().startswith("bearer "):
|
| 35 |
+
return auth_header[7:].strip()
|
| 36 |
+
return request.headers.get("x-api-key")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def verify_api_key(request: Request) -> str:
|
| 40 |
+
token = _extract_token(request)
|
| 41 |
+
if not token:
|
| 42 |
+
raise HTTPException(status_code=401, detail="Missing token")
|
| 43 |
+
# Try JWT first
|
| 44 |
+
try:
|
| 45 |
+
payload = jwt.decode(token, _JWT_SECRET, algorithms=["HS256"])
|
| 46 |
+
if payload.get("exp", 0) > time.time():
|
| 47 |
+
return token
|
| 48 |
+
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
|
| 49 |
+
pass
|
| 50 |
+
# Fallback to API key
|
| 51 |
+
if _key_manager and _key_manager.validate(token):
|
| 52 |
+
return token
|
| 53 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
src/core/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Config loader with hot-reload support."""
|
| 2 |
+
import os
|
| 3 |
+
try:
|
| 4 |
+
import tomllib
|
| 5 |
+
except ModuleNotFoundError:
|
| 6 |
+
import tomli as tomllib
|
| 7 |
+
import tomli_w
|
| 8 |
+
|
| 9 |
+
_CONFIG_PATH = os.path.abspath(
|
| 10 |
+
os.path.join(os.path.dirname(__file__), "..", "..", "config", "setting.toml")
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _load():
|
| 15 |
+
with open(_CONFIG_PATH, "rb") as f:
|
| 16 |
+
return tomllib.load(f)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _save(cfg: dict):
|
| 20 |
+
with open(_CONFIG_PATH, "wb") as f:
|
| 21 |
+
tomli_w.dump(cfg, f)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
_cfg = _load()
|
| 25 |
+
|
| 26 |
+
API_KEY = _cfg["global"]["api_key"]
|
| 27 |
+
HOST = _cfg["server"]["host"]
|
| 28 |
+
PORT = _cfg["server"]["port"]
|
| 29 |
+
|
| 30 |
+
# Admin credentials
|
| 31 |
+
ADMIN_USERNAME = _cfg.get("admin", {}).get("username", "admin")
|
| 32 |
+
ADMIN_PASSWORD = _cfg.get("admin", {}).get("password", "admin")
|
| 33 |
+
|
| 34 |
+
# Proxy
|
| 35 |
+
PROXY_URL = _cfg.get("proxy", {}).get("url", "")
|
| 36 |
+
|
| 37 |
+
# Retry
|
| 38 |
+
MAX_RETRIES = _cfg.get("retry", {}).get("max_retries", 3)
|
| 39 |
+
RETRY_DELAY = _cfg.get("retry", {}).get("retry_delay", 1)
|
| 40 |
+
|
| 41 |
+
# OB-1 config
|
| 42 |
+
OB1_CREDENTIALS_PATH = _cfg["ob1"].get("credentials_path", "")
|
| 43 |
+
OB1_WORKOS_AUTH_URL = _cfg["ob1"]["workos_auth_url"]
|
| 44 |
+
OB1_WORKOS_CLIENT_ID = _cfg["ob1"]["workos_client_id"]
|
| 45 |
+
OB1_API_BASE = _cfg["ob1"]["api_base"]
|
| 46 |
+
OB1_REFRESH_BUFFER = _cfg["ob1"].get("refresh_buffer_seconds", 600)
|
| 47 |
+
OB1_ROTATION_MODE = _cfg["ob1"].get("rotation_mode", "cache-first")
|
| 48 |
+
OB1_REFRESH_INTERVAL = _cfg["ob1"].get("refresh_interval", 0)
|
| 49 |
+
|
| 50 |
+
# Logging
|
| 51 |
+
LOG_LEVEL = _cfg.get("logging", {}).get("level", "INFO")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def reload():
|
| 55 |
+
"""Reload config from disk into module-level variables."""
|
| 56 |
+
global _cfg, API_KEY, ADMIN_USERNAME, ADMIN_PASSWORD, PROXY_URL, MAX_RETRIES, RETRY_DELAY, OB1_ROTATION_MODE, OB1_REFRESH_INTERVAL, LOG_LEVEL
|
| 57 |
+
_cfg = _load()
|
| 58 |
+
API_KEY = _cfg["global"]["api_key"]
|
| 59 |
+
ADMIN_USERNAME = _cfg.get("admin", {}).get("username", "admin")
|
| 60 |
+
ADMIN_PASSWORD = _cfg.get("admin", {}).get("password", "admin")
|
| 61 |
+
PROXY_URL = _cfg.get("proxy", {}).get("url", "")
|
| 62 |
+
MAX_RETRIES = _cfg.get("retry", {}).get("max_retries", 3)
|
| 63 |
+
RETRY_DELAY = _cfg.get("retry", {}).get("retry_delay", 1)
|
| 64 |
+
OB1_ROTATION_MODE = _cfg["ob1"].get("rotation_mode", "cache-first")
|
| 65 |
+
OB1_REFRESH_INTERVAL = _cfg["ob1"].get("refresh_interval", 0)
|
| 66 |
+
LOG_LEVEL = _cfg.get("logging", {}).get("level", "INFO")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def update_setting(section: str, key: str, value):
|
| 70 |
+
"""Update a single setting, persist to disk, and reload."""
|
| 71 |
+
cfg = _load()
|
| 72 |
+
if section not in cfg:
|
| 73 |
+
cfg[section] = {}
|
| 74 |
+
cfg[section][key] = value
|
| 75 |
+
_save(cfg)
|
| 76 |
+
reload()
|
src/core/logger.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized logging configuration."""
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
| 6 |
+
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def setup_logging(level: str = "INFO"):
|
| 10 |
+
"""Configure root logger with console output."""
|
| 11 |
+
root = logging.getLogger("ob1")
|
| 12 |
+
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
| 13 |
+
if not root.handlers:
|
| 14 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 15 |
+
handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE_FORMAT))
|
| 16 |
+
root.addHandler(handler)
|
| 17 |
+
return root
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_logger(name: str) -> logging.Logger:
|
| 21 |
+
return logging.getLogger(f"ob1.{name}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def set_level(level: str):
|
| 25 |
+
"""Dynamically change log level."""
|
| 26 |
+
root = logging.getLogger("ob1")
|
| 27 |
+
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
src/core/models.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for OB1 2API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
from typing import Any, List, Optional, Union
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ChatMessage(BaseModel):
|
| 9 |
+
role: str
|
| 10 |
+
content: Union[str, list]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ChatCompletionRequest(BaseModel):
|
| 14 |
+
model: str = "anthropic/claude-opus-4.6"
|
| 15 |
+
messages: List[ChatMessage]
|
| 16 |
+
stream: bool = False
|
| 17 |
+
temperature: Optional[float] = None
|
| 18 |
+
top_p: Optional[float] = None
|
| 19 |
+
max_tokens: Optional[int] = None
|
| 20 |
+
tools: Optional[List[dict[str, Any]]] = None
|
| 21 |
+
tool_choice: Optional[Union[str, dict[str, Any]]] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AnthropicMessage(BaseModel):
|
| 25 |
+
role: str
|
| 26 |
+
content: Union[str, List[dict[str, Any]]]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AnthropicMessagesRequest(BaseModel):
|
| 30 |
+
model: str = "anthropic/claude-opus-4.6"
|
| 31 |
+
messages: List[AnthropicMessage]
|
| 32 |
+
max_tokens: int = 4096
|
| 33 |
+
system: Optional[Union[str, List[dict[str, Any]]]] = None
|
| 34 |
+
stream: bool = False
|
| 35 |
+
temperature: Optional[float] = None
|
| 36 |
+
top_p: Optional[float] = None
|
| 37 |
+
tools: Optional[List[dict[str, Any]]] = None
|
| 38 |
+
tool_choice: Optional[dict[str, Any]] = None
|
src/main.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OB1 2API — FastAPI application."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
from fastapi.responses import RedirectResponse
|
| 8 |
+
|
| 9 |
+
from .api import routes, admin
|
| 10 |
+
from .services.token_manager import OB1TokenManager
|
| 11 |
+
from .services.ob1_client import OB1Client
|
| 12 |
+
from .services.api_key_manager import ApiKeyManager
|
| 13 |
+
from .core.auth import init_auth
|
| 14 |
+
from .core.config import API_KEY
|
| 15 |
+
from .core import config as _config
|
| 16 |
+
from .core.logger import setup_logging, get_logger
|
| 17 |
+
|
| 18 |
+
setup_logging()
|
| 19 |
+
log = get_logger("main")
|
| 20 |
+
|
| 21 |
+
app = FastAPI(title="OB1 2API", version="1.0.0")
|
| 22 |
+
|
| 23 |
+
# Auto-refresh task handle
|
| 24 |
+
_auto_refresh_task: asyncio.Task | None = None
|
| 25 |
+
|
| 26 |
+
# Static files
|
| 27 |
+
_static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
| 28 |
+
app.mount("/static", StaticFiles(directory=os.path.abspath(_static_dir)), name="static")
|
| 29 |
+
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=["*"],
|
| 33 |
+
allow_methods=["*"],
|
| 34 |
+
allow_headers=["*"],
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Services
|
| 38 |
+
token_manager = OB1TokenManager()
|
| 39 |
+
ob1_client = OB1Client()
|
| 40 |
+
api_key_manager = ApiKeyManager()
|
| 41 |
+
|
| 42 |
+
# Init auth with key manager
|
| 43 |
+
init_auth(api_key_manager)
|
| 44 |
+
|
| 45 |
+
# Inject dependencies
|
| 46 |
+
routes.init(token_manager, ob1_client)
|
| 47 |
+
admin.init(token_manager, api_key_manager)
|
| 48 |
+
|
| 49 |
+
# Register routers
|
| 50 |
+
app.include_router(routes.router)
|
| 51 |
+
app.include_router(admin.login_router)
|
| 52 |
+
app.include_router(admin.router)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.on_event("startup")
|
| 56 |
+
async def startup():
|
| 57 |
+
api_key_manager.load(default_key=API_KEY)
|
| 58 |
+
token_manager.load()
|
| 59 |
+
if token_manager.is_loaded:
|
| 60 |
+
api_key = await token_manager.get_api_key()
|
| 61 |
+
log.info("OB1 2API started — user=%s token=%s", token_manager.user_email, "valid" if api_key else "needs refresh")
|
| 62 |
+
else:
|
| 63 |
+
log.info("OB1 2API started (no credentials loaded)")
|
| 64 |
+
# Periodic flush for api key stats
|
| 65 |
+
asyncio.create_task(_periodic_flush())
|
| 66 |
+
# Start auto-refresh if configured
|
| 67 |
+
restart_auto_refresh()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def _periodic_flush():
|
| 71 |
+
while True:
|
| 72 |
+
await asyncio.sleep(60)
|
| 73 |
+
api_key_manager.flush()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _auto_refresh_loop(interval: int):
|
| 77 |
+
"""Periodically refresh all account tokens."""
|
| 78 |
+
while True:
|
| 79 |
+
await asyncio.sleep(interval * 60)
|
| 80 |
+
log.info("Auto-refreshing all accounts (interval=%dm)", interval)
|
| 81 |
+
await token_manager.refresh()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def restart_auto_refresh():
|
| 85 |
+
"""(Re)start the auto-refresh task based on current config."""
|
| 86 |
+
global _auto_refresh_task
|
| 87 |
+
if _auto_refresh_task and not _auto_refresh_task.done():
|
| 88 |
+
_auto_refresh_task.cancel()
|
| 89 |
+
_auto_refresh_task = None
|
| 90 |
+
_config.reload()
|
| 91 |
+
interval = _config.OB1_REFRESH_INTERVAL
|
| 92 |
+
if interval and interval > 0:
|
| 93 |
+
_auto_refresh_task = asyncio.create_task(_auto_refresh_loop(interval))
|
| 94 |
+
log.info("Auto-refresh enabled: every %d minutes", interval)
|
| 95 |
+
else:
|
| 96 |
+
log.info("Auto-refresh disabled")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.on_event("shutdown")
|
| 100 |
+
async def shutdown():
|
| 101 |
+
api_key_manager.flush()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.get("/")
|
| 105 |
+
async def root():
|
| 106 |
+
return RedirectResponse("/static/login.html")
|
src/services/__init__.py
ADDED
|
File without changes
|
src/services/api_key_manager.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API key manager — multi-key support with CRUD."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import secrets
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
from ..core.logger import get_logger
|
| 10 |
+
|
| 11 |
+
log = get_logger("api-keys")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _keys_path() -> str:
|
| 15 |
+
return os.path.join(os.path.dirname(__file__), "..", "..", "config", "api_keys.json")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ApiKey:
|
| 19 |
+
def __init__(self, data: dict):
|
| 20 |
+
self.key: str = data["key"]
|
| 21 |
+
self.name: str = data.get("name", "")
|
| 22 |
+
self.created_at: float = data.get("created_at", 0)
|
| 23 |
+
self.last_used: float = data.get("last_used", 0)
|
| 24 |
+
self.requests: int = data.get("requests", 0)
|
| 25 |
+
self.enabled: bool = data.get("enabled", True)
|
| 26 |
+
|
| 27 |
+
def to_dict(self) -> dict:
|
| 28 |
+
return {
|
| 29 |
+
"key": self.key,
|
| 30 |
+
"name": self.name,
|
| 31 |
+
"created_at": self.created_at,
|
| 32 |
+
"last_used": self.last_used,
|
| 33 |
+
"requests": self.requests,
|
| 34 |
+
"enabled": self.enabled,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
def to_public(self) -> dict:
|
| 38 |
+
k = self.key
|
| 39 |
+
masked = k[:7] + "..." + k[-4:] if len(k) > 12 else k
|
| 40 |
+
return {
|
| 41 |
+
"key": masked,
|
| 42 |
+
"full_key": self.key,
|
| 43 |
+
"name": self.name,
|
| 44 |
+
"created_at": int(self.created_at * 1000),
|
| 45 |
+
"last_used": int(self.last_used * 1000) if self.last_used else 0,
|
| 46 |
+
"requests": self.requests,
|
| 47 |
+
"enabled": self.enabled,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ApiKeyManager:
|
| 52 |
+
def __init__(self):
|
| 53 |
+
self._keys: list[ApiKey] = []
|
| 54 |
+
self._path = _keys_path()
|
| 55 |
+
self._dirty = False
|
| 56 |
+
|
| 57 |
+
def load(self, default_key: str = ""):
|
| 58 |
+
if os.path.exists(self._path):
|
| 59 |
+
with open(self._path, "r", encoding="utf-8") as f:
|
| 60 |
+
data = json.load(f)
|
| 61 |
+
self._keys = [ApiKey(k) for k in data]
|
| 62 |
+
if not self._keys and default_key:
|
| 63 |
+
self._keys.append(ApiKey({
|
| 64 |
+
"key": default_key,
|
| 65 |
+
"name": "默认密钥",
|
| 66 |
+
"created_at": time.time(),
|
| 67 |
+
}))
|
| 68 |
+
self._save()
|
| 69 |
+
log.info("Loaded %d keys", len(self._keys))
|
| 70 |
+
|
| 71 |
+
def _save(self):
|
| 72 |
+
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
| 73 |
+
with open(self._path, "w", encoding="utf-8") as f:
|
| 74 |
+
json.dump([k.to_dict() for k in self._keys], f, indent=2)
|
| 75 |
+
|
| 76 |
+
def validate(self, key: str) -> bool:
|
| 77 |
+
for k in self._keys:
|
| 78 |
+
if k.key == key and k.enabled:
|
| 79 |
+
k.last_used = time.time()
|
| 80 |
+
k.requests += 1
|
| 81 |
+
self._dirty = True
|
| 82 |
+
return True
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
def flush(self):
|
| 86 |
+
"""Persist pending changes to disk."""
|
| 87 |
+
if self._dirty:
|
| 88 |
+
self._save()
|
| 89 |
+
self._dirty = False
|
| 90 |
+
|
| 91 |
+
def list_keys(self) -> list[dict]:
|
| 92 |
+
return [k.to_public() for k in self._keys]
|
| 93 |
+
|
| 94 |
+
def create_key(self, name: str = "") -> dict:
|
| 95 |
+
key = "sk-" + secrets.token_hex(24)
|
| 96 |
+
ak = ApiKey({
|
| 97 |
+
"key": key,
|
| 98 |
+
"name": name or f"Key-{len(self._keys) + 1}",
|
| 99 |
+
"created_at": time.time(),
|
| 100 |
+
})
|
| 101 |
+
self._keys.append(ak)
|
| 102 |
+
self._save()
|
| 103 |
+
return ak.to_public()
|
| 104 |
+
|
| 105 |
+
def create_key_with_value(self, key: str, name: str = "") -> dict:
|
| 106 |
+
"""Create a key with a specific value (for syncing from settings)."""
|
| 107 |
+
for k in self._keys:
|
| 108 |
+
if k.key == key:
|
| 109 |
+
return k.to_public()
|
| 110 |
+
ak = ApiKey({
|
| 111 |
+
"key": key,
|
| 112 |
+
"name": name or f"Key-{len(self._keys) + 1}",
|
| 113 |
+
"created_at": time.time(),
|
| 114 |
+
})
|
| 115 |
+
self._keys.append(ak)
|
| 116 |
+
self._save()
|
| 117 |
+
return ak.to_public()
|
| 118 |
+
|
| 119 |
+
def delete_key(self, key: str) -> bool:
|
| 120 |
+
for i, k in enumerate(self._keys):
|
| 121 |
+
if k.key == key:
|
| 122 |
+
self._keys.pop(i)
|
| 123 |
+
self._save()
|
| 124 |
+
return True
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
def toggle_key(self, key: str) -> bool:
|
| 128 |
+
for k in self._keys:
|
| 129 |
+
if k.key == key:
|
| 130 |
+
k.enabled = not k.enabled
|
| 131 |
+
self._save()
|
| 132 |
+
return True
|
| 133 |
+
return False
|
src/services/ob1_client.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OB-1 API client — proxies requests to dashboard.openblocklabs.com/api/v1."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from ..core import config as _config
|
| 9 |
+
from ..core.config import OB1_API_BASE
|
| 10 |
+
from ..core.logger import get_logger
|
| 11 |
+
|
| 12 |
+
log = get_logger("client")
|
| 13 |
+
|
| 14 |
+
_HEADERS = {
|
| 15 |
+
"HTTP-Referer": "https://github.com/delta-hq/ob1",
|
| 16 |
+
"X-Title": "OB1 CLI",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class StreamResponse:
|
| 21 |
+
"""Wrapper that keeps httpx client alive during streaming."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, resp: httpx.Response, client: httpx.AsyncClient):
|
| 24 |
+
self._resp = resp
|
| 25 |
+
self._client = client
|
| 26 |
+
|
| 27 |
+
def __getattr__(self, name):
|
| 28 |
+
return getattr(self._resp, name)
|
| 29 |
+
|
| 30 |
+
async def aclose(self):
|
| 31 |
+
await self._resp.aclose()
|
| 32 |
+
await self._client.aclose()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class OB1Client:
|
| 36 |
+
"""Async HTTP client to OBL OpenRouter-compatible API."""
|
| 37 |
+
|
| 38 |
+
def __init__(self):
|
| 39 |
+
self.base_url = OB1_API_BASE
|
| 40 |
+
self._models_cache: list | None = None
|
| 41 |
+
|
| 42 |
+
def _proxy(self) -> str | None:
|
| 43 |
+
url = _config.PROXY_URL
|
| 44 |
+
return url if url else None
|
| 45 |
+
|
| 46 |
+
async def fetch_models(self, api_key: str) -> list:
|
| 47 |
+
"""Fetch available models from OB-1. Cached after first call."""
|
| 48 |
+
if self._models_cache is not None:
|
| 49 |
+
return self._models_cache
|
| 50 |
+
try:
|
| 51 |
+
log.debug("Fetching models from %s/models", self.base_url)
|
| 52 |
+
async with httpx.AsyncClient(timeout=15, proxy=self._proxy()) as client:
|
| 53 |
+
resp = await client.get(
|
| 54 |
+
f"{self.base_url}/models",
|
| 55 |
+
headers={**_HEADERS, "Authorization": f"Bearer {api_key}"},
|
| 56 |
+
)
|
| 57 |
+
if resp.status_code == 200:
|
| 58 |
+
self._models_cache = resp.json().get("data", [])
|
| 59 |
+
log.info("Fetched %d models", len(self._models_cache))
|
| 60 |
+
return self._models_cache
|
| 61 |
+
log.warning("Models fetch returned %d", resp.status_code)
|
| 62 |
+
except Exception as e:
|
| 63 |
+
log.error("Models fetch failed: %s", e)
|
| 64 |
+
return []
|
| 65 |
+
|
| 66 |
+
async def chat(
|
| 67 |
+
self,
|
| 68 |
+
api_key: str,
|
| 69 |
+
messages: list,
|
| 70 |
+
model: str = "anthropic/claude-opus-4.6",
|
| 71 |
+
stream: bool = False,
|
| 72 |
+
temperature: float | None = None,
|
| 73 |
+
top_p: float | None = None,
|
| 74 |
+
max_tokens: int | None = None,
|
| 75 |
+
extra_payload: dict[str, Any] | None = None,
|
| 76 |
+
) -> httpx.Response:
|
| 77 |
+
"""Send chat completion request. Returns raw httpx Response."""
|
| 78 |
+
payload = {
|
| 79 |
+
"model": model,
|
| 80 |
+
"messages": messages,
|
| 81 |
+
"stream": stream,
|
| 82 |
+
}
|
| 83 |
+
if temperature is not None:
|
| 84 |
+
payload["temperature"] = temperature
|
| 85 |
+
if top_p is not None:
|
| 86 |
+
payload["top_p"] = top_p
|
| 87 |
+
if max_tokens is not None:
|
| 88 |
+
payload["max_tokens"] = max_tokens
|
| 89 |
+
if stream:
|
| 90 |
+
payload["stream_options"] = {"include_usage": True}
|
| 91 |
+
if extra_payload:
|
| 92 |
+
payload.update(extra_payload)
|
| 93 |
+
|
| 94 |
+
headers = {
|
| 95 |
+
**_HEADERS,
|
| 96 |
+
"Authorization": f"Bearer {api_key}",
|
| 97 |
+
"Content-Type": "application/json",
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
client = httpx.AsyncClient(timeout=300, proxy=self._proxy())
|
| 101 |
+
try:
|
| 102 |
+
if stream:
|
| 103 |
+
log.debug("Sending stream request to %s model=%s", self.base_url, model)
|
| 104 |
+
req = client.build_request(
|
| 105 |
+
"POST",
|
| 106 |
+
f"{self.base_url}/chat/completions",
|
| 107 |
+
json=payload,
|
| 108 |
+
headers=headers,
|
| 109 |
+
)
|
| 110 |
+
resp = await client.send(req, stream=True)
|
| 111 |
+
return StreamResponse(resp, client)
|
| 112 |
+
else:
|
| 113 |
+
log.debug("Sending request to %s model=%s", self.base_url, model)
|
| 114 |
+
resp = await client.post(
|
| 115 |
+
f"{self.base_url}/chat/completions",
|
| 116 |
+
json=payload,
|
| 117 |
+
headers=headers,
|
| 118 |
+
)
|
| 119 |
+
log.debug("Response status=%d", resp.status_code)
|
| 120 |
+
await client.aclose()
|
| 121 |
+
return resp
|
| 122 |
+
except Exception as e:
|
| 123 |
+
log.error("Request failed: %s", e)
|
| 124 |
+
await client.aclose()
|
| 125 |
+
raise
|
src/services/token_manager.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OB-1 multi-account token manager."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import time
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
from ..core.config import (
|
| 11 |
+
OB1_WORKOS_AUTH_URL,
|
| 12 |
+
OB1_WORKOS_CLIENT_ID,
|
| 13 |
+
OB1_REFRESH_BUFFER,
|
| 14 |
+
OB1_API_BASE,
|
| 15 |
+
)
|
| 16 |
+
from ..core import config as _config
|
| 17 |
+
from ..core.logger import get_logger
|
| 18 |
+
|
| 19 |
+
log = get_logger("token")
|
| 20 |
+
|
| 21 |
+
DEVICE_AUTH_URL = "https://api.workos.com/user_management/authorize/device"
|
| 22 |
+
ORG_API_URL = f"{OB1_API_BASE}/auth/organizations"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _accounts_path() -> str:
|
| 26 |
+
return os.path.join(os.path.dirname(__file__), "..", "..", "config", "accounts.json")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Account:
|
| 30 |
+
def __init__(self, data: dict):
|
| 31 |
+
self.email: str = data.get("email", "")
|
| 32 |
+
self.access_token: str = data.get("access_token", "")
|
| 33 |
+
self.refresh_token: str = data.get("refresh_token", "")
|
| 34 |
+
self.expires_at: float = data.get("expires_at", 0)
|
| 35 |
+
self.org_id: str = data.get("org_id", "")
|
| 36 |
+
self.org_name: str = data.get("org_name", "")
|
| 37 |
+
self.user_id: str = data.get("user_id", "")
|
| 38 |
+
self.user_data: dict = data.get("user_data", {})
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def active(self) -> bool:
|
| 42 |
+
return bool(self.access_token) and self.expires_at > time.time()
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> dict:
|
| 45 |
+
return {
|
| 46 |
+
"email": self.email,
|
| 47 |
+
"access_token": self.access_token,
|
| 48 |
+
"refresh_token": self.refresh_token,
|
| 49 |
+
"expires_at": self.expires_at,
|
| 50 |
+
"org_id": self.org_id,
|
| 51 |
+
"org_name": self.org_name,
|
| 52 |
+
"user_id": self.user_id,
|
| 53 |
+
"user_data": self.user_data,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
@staticmethod
|
| 57 |
+
def _mask(token: str) -> str:
|
| 58 |
+
if not token:
|
| 59 |
+
return ""
|
| 60 |
+
if len(token) <= 8:
|
| 61 |
+
return token[:2] + "..." + token[-2:]
|
| 62 |
+
return token[:4] + "..." + token[-4:]
|
| 63 |
+
|
| 64 |
+
def to_public(self) -> dict:
|
| 65 |
+
return {
|
| 66 |
+
"email": self.email,
|
| 67 |
+
"org_id": self.org_id,
|
| 68 |
+
"org_name": self.org_name,
|
| 69 |
+
"at_mask": self._mask(self.access_token),
|
| 70 |
+
"rt_mask": self._mask(self.refresh_token),
|
| 71 |
+
"active": self.active,
|
| 72 |
+
"expires_at": int(self.expires_at * 1000),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class OB1TokenManager:
|
| 77 |
+
"""Manages multiple OB-1 accounts with round-robin and auto-refresh."""
|
| 78 |
+
|
| 79 |
+
def __init__(self):
|
| 80 |
+
self._accounts: list[Account] = []
|
| 81 |
+
self._current_idx: int = 0
|
| 82 |
+
self._path = _accounts_path()
|
| 83 |
+
self._request_count: int = 0
|
| 84 |
+
self._cost_today: float = 0
|
| 85 |
+
|
| 86 |
+
def load(self):
|
| 87 |
+
# Load from accounts.json
|
| 88 |
+
if os.path.exists(self._path):
|
| 89 |
+
with open(self._path, "r", encoding="utf-8") as f:
|
| 90 |
+
data = json.load(f)
|
| 91 |
+
self._accounts = [Account(a) for a in data]
|
| 92 |
+
log.info("Loaded %d accounts", len(self._accounts))
|
| 93 |
+
# Also import from ~/.ob1/credentials.json if accounts.json is empty
|
| 94 |
+
if not self._accounts:
|
| 95 |
+
cred_path = os.path.join(os.path.expanduser("~"), ".ob1", "credentials.json")
|
| 96 |
+
if os.path.exists(cred_path):
|
| 97 |
+
self._import_credentials(cred_path)
|
| 98 |
+
|
| 99 |
+
def _import_credentials(self, path: str):
|
| 100 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 101 |
+
data = json.load(f)
|
| 102 |
+
oauth = data.get("oauth", {})
|
| 103 |
+
if not oauth.get("access_token"):
|
| 104 |
+
return
|
| 105 |
+
user = oauth.get("user", {})
|
| 106 |
+
acct = Account({
|
| 107 |
+
"email": user.get("email", ""),
|
| 108 |
+
"access_token": oauth.get("access_token", ""),
|
| 109 |
+
"refresh_token": oauth.get("refresh_token", ""),
|
| 110 |
+
"expires_at": oauth.get("expires_at", 0) / 1000,
|
| 111 |
+
"org_id": oauth.get("organization_id", ""),
|
| 112 |
+
"user_id": user.get("id", ""),
|
| 113 |
+
"user_data": user,
|
| 114 |
+
})
|
| 115 |
+
self._accounts.append(acct)
|
| 116 |
+
self._save()
|
| 117 |
+
log.info("Imported %s from credentials.json", acct.email)
|
| 118 |
+
|
| 119 |
+
def _save(self):
|
| 120 |
+
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
| 121 |
+
with open(self._path, "w", encoding="utf-8") as f:
|
| 122 |
+
json.dump([a.to_dict() for a in self._accounts], f, indent=2)
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def is_loaded(self) -> bool:
|
| 126 |
+
return len(self._accounts) > 0
|
| 127 |
+
|
| 128 |
+
@property
|
| 129 |
+
def user_email(self) -> str:
|
| 130 |
+
if self._accounts:
|
| 131 |
+
return self._accounts[0].email
|
| 132 |
+
return ""
|
| 133 |
+
|
| 134 |
+
@property
|
| 135 |
+
def org_id(self) -> str:
|
| 136 |
+
if self._accounts:
|
| 137 |
+
return self._accounts[0].org_id
|
| 138 |
+
return ""
|
| 139 |
+
|
| 140 |
+
def list_accounts(self) -> list[dict]:
|
| 141 |
+
return [a.to_public() for a in self._accounts]
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def current_idx(self) -> int:
|
| 145 |
+
return self._current_idx
|
| 146 |
+
|
| 147 |
+
@property
|
| 148 |
+
def stats(self) -> dict:
|
| 149 |
+
active = sum(1 for a in self._accounts if a.active)
|
| 150 |
+
return {
|
| 151 |
+
"total": len(self._accounts),
|
| 152 |
+
"active": active,
|
| 153 |
+
"cost": self._cost_today,
|
| 154 |
+
"requests": self._request_count,
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
def add_cost(self, cost: float):
|
| 158 |
+
self._cost_today += cost
|
| 159 |
+
self._request_count += 1
|
| 160 |
+
|
| 161 |
+
async def refresh_account(self, idx: int, force: bool = False) -> bool:
|
| 162 |
+
if idx < 0 or idx >= len(self._accounts):
|
| 163 |
+
return False
|
| 164 |
+
acct = self._accounts[idx]
|
| 165 |
+
if not acct.refresh_token:
|
| 166 |
+
return False
|
| 167 |
+
# Skip if token still valid (not within buffer), unless forced
|
| 168 |
+
if not force and acct.expires_at - time.time() > OB1_REFRESH_BUFFER:
|
| 169 |
+
log.debug("Skipping refresh for %s, token still valid (%.0fh remaining)",
|
| 170 |
+
acct.email, (acct.expires_at - time.time()) / 3600)
|
| 171 |
+
return True
|
| 172 |
+
try:
|
| 173 |
+
proxy = _config.PROXY_URL or None
|
| 174 |
+
async with httpx.AsyncClient(proxy=proxy, timeout=30) as client:
|
| 175 |
+
resp = await client.post(
|
| 176 |
+
OB1_WORKOS_AUTH_URL,
|
| 177 |
+
data={
|
| 178 |
+
"grant_type": "refresh_token",
|
| 179 |
+
"refresh_token": acct.refresh_token,
|
| 180 |
+
"client_id": OB1_WORKOS_CLIENT_ID,
|
| 181 |
+
},
|
| 182 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 183 |
+
)
|
| 184 |
+
if resp.status_code != 200:
|
| 185 |
+
log.warning("Refresh failed for %s: %d %s", acct.email, resp.status_code, resp.text)
|
| 186 |
+
return False
|
| 187 |
+
result = resp.json()
|
| 188 |
+
acct.access_token = result["access_token"]
|
| 189 |
+
acct.refresh_token = result.get("refresh_token", acct.refresh_token)
|
| 190 |
+
acct.expires_at = time.time() + result.get("expires_in", 3600)
|
| 191 |
+
self._save()
|
| 192 |
+
log.info("Refreshed %s", acct.email)
|
| 193 |
+
return True
|
| 194 |
+
except Exception as e:
|
| 195 |
+
log.error("Refresh error for %s: %s", acct.email, e)
|
| 196 |
+
return False
|
| 197 |
+
|
| 198 |
+
def remove_account(self, idx: int) -> bool:
|
| 199 |
+
if idx < 0 or idx >= len(self._accounts):
|
| 200 |
+
return False
|
| 201 |
+
removed = self._accounts.pop(idx)
|
| 202 |
+
self._save()
|
| 203 |
+
log.info("Removed %s", removed.email)
|
| 204 |
+
return True
|
| 205 |
+
|
| 206 |
+
async def add_account_from_device(self, auth_result: dict) -> str:
|
| 207 |
+
"""Add account from device auth result. Returns email."""
|
| 208 |
+
user = auth_result.get("user", {})
|
| 209 |
+
at = auth_result["access_token"]
|
| 210 |
+
rt = auth_result["refresh_token"]
|
| 211 |
+
expires_in = auth_result.get("expires_in", 3600)
|
| 212 |
+
user_id = user.get("id", "")
|
| 213 |
+
email = user.get("email", "")
|
| 214 |
+
|
| 215 |
+
# Fetch org
|
| 216 |
+
org_id = ""
|
| 217 |
+
org_name = ""
|
| 218 |
+
try:
|
| 219 |
+
proxy = _config.PROXY_URL or None
|
| 220 |
+
async with httpx.AsyncClient(proxy=proxy, timeout=15) as client:
|
| 221 |
+
resp = await client.get(
|
| 222 |
+
f"{ORG_API_URL}?user_id={user_id}",
|
| 223 |
+
headers={"Authorization": f"Bearer {at}"},
|
| 224 |
+
)
|
| 225 |
+
if resp.status_code == 200:
|
| 226 |
+
orgs = resp.json().get("data", [])
|
| 227 |
+
if orgs:
|
| 228 |
+
org_id = orgs[0].get("organizationId", "")
|
| 229 |
+
org_name = orgs[0].get("organizationName", "")
|
| 230 |
+
except Exception as e:
|
| 231 |
+
log.error("Org fetch error: %s", e)
|
| 232 |
+
|
| 233 |
+
# Check duplicate
|
| 234 |
+
for a in self._accounts:
|
| 235 |
+
if a.email == email:
|
| 236 |
+
a.access_token = at
|
| 237 |
+
a.refresh_token = rt
|
| 238 |
+
a.expires_at = time.time() + expires_in
|
| 239 |
+
a.org_id = org_id or a.org_id
|
| 240 |
+
a.org_name = org_name or a.org_name
|
| 241 |
+
self._save()
|
| 242 |
+
return email
|
| 243 |
+
|
| 244 |
+
acct = Account({
|
| 245 |
+
"email": email,
|
| 246 |
+
"access_token": at,
|
| 247 |
+
"refresh_token": rt,
|
| 248 |
+
"expires_at": time.time() + expires_in,
|
| 249 |
+
"org_id": org_id,
|
| 250 |
+
"org_name": org_name,
|
| 251 |
+
"user_id": user_id,
|
| 252 |
+
"user_data": user,
|
| 253 |
+
})
|
| 254 |
+
self._accounts.append(acct)
|
| 255 |
+
self._save()
|
| 256 |
+
log.info("Added account %s (org: %s)", email, org_name)
|
| 257 |
+
return email
|
| 258 |
+
|
| 259 |
+
async def get_api_key(self) -> str | None:
|
| 260 |
+
"""Get a valid API key based on rotation mode."""
|
| 261 |
+
if not self._accounts:
|
| 262 |
+
return None
|
| 263 |
+
n = len(self._accounts)
|
| 264 |
+
mode = _config.OB1_ROTATION_MODE
|
| 265 |
+
|
| 266 |
+
if mode == "performance":
|
| 267 |
+
order = random.sample(range(n), n)
|
| 268 |
+
elif mode == "cache-first":
|
| 269 |
+
# 优先使用上次成功的账号
|
| 270 |
+
order = [self._current_idx] + [i for i in range(n) if i != self._current_idx]
|
| 271 |
+
else: # balanced (default) — 轮流使用
|
| 272 |
+
order = [(self._current_idx + i) % n for i in range(n)]
|
| 273 |
+
self._current_idx = (self._current_idx + 1) % n
|
| 274 |
+
|
| 275 |
+
for idx in order:
|
| 276 |
+
acct = self._accounts[idx]
|
| 277 |
+
if acct.expires_at - time.time() < OB1_REFRESH_BUFFER:
|
| 278 |
+
await self.refresh_account(idx)
|
| 279 |
+
if acct.active:
|
| 280 |
+
if acct.org_id:
|
| 281 |
+
return f"{acct.access_token}:{acct.org_id}"
|
| 282 |
+
return acct.access_token
|
| 283 |
+
return None
|
| 284 |
+
|
| 285 |
+
async def refresh(self) -> bool:
|
| 286 |
+
"""Refresh all accounts."""
|
| 287 |
+
ok = False
|
| 288 |
+
for i in range(len(self._accounts)):
|
| 289 |
+
if await self.refresh_account(i):
|
| 290 |
+
ok = True
|
| 291 |
+
return ok
|
| 292 |
+
|
| 293 |
+
def import_accounts(self, data: list[dict]) -> int:
|
| 294 |
+
"""Import accounts from a list of dicts, skip duplicates by email."""
|
| 295 |
+
existing = {a.email for a in self._accounts}
|
| 296 |
+
count = 0
|
| 297 |
+
for d in data:
|
| 298 |
+
if d.get("email") and d["email"] not in existing:
|
| 299 |
+
self._accounts.append(Account(d))
|
| 300 |
+
existing.add(d["email"])
|
| 301 |
+
count += 1
|
| 302 |
+
if count:
|
| 303 |
+
self._save()
|
| 304 |
+
return count
|
| 305 |
+
|
| 306 |
+
def batch_remove(self, indices: list[int]) -> int:
|
| 307 |
+
"""Remove accounts by indices (descending to keep order)."""
|
| 308 |
+
removed = 0
|
| 309 |
+
for i in sorted(indices, reverse=True):
|
| 310 |
+
if 0 <= i < len(self._accounts):
|
| 311 |
+
self._accounts.pop(i)
|
| 312 |
+
removed += 1
|
| 313 |
+
if removed:
|
| 314 |
+
self._save()
|
| 315 |
+
return removed
|
static/login.html
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>登录 - OB1 2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 10 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
| 11 |
+
</style>
|
| 12 |
+
<script>
|
| 13 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
| 14 |
+
</script>
|
| 15 |
+
</head>
|
| 16 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 17 |
+
<div class="flex min-h-full flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
|
| 18 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 19 |
+
<div class="text-center">
|
| 20 |
+
<h1 class="text-4xl font-bold">OB1 2API</h1>
|
| 21 |
+
<p class="mt-2 text-sm text-muted-foreground">管理员控制台</p>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 25 |
+
<div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
|
| 26 |
+
<form id="loginForm" class="space-y-6">
|
| 27 |
+
<div class="space-y-2">
|
| 28 |
+
<label for="username" class="text-sm font-medium">账户</label>
|
| 29 |
+
<input type="text" id="username" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" placeholder="请输入账户">
|
| 30 |
+
</div>
|
| 31 |
+
<div class="space-y-2">
|
| 32 |
+
<label for="password" class="text-sm font-medium">密码</label>
|
| 33 |
+
<input type="password" id="password" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" placeholder="请输入密码">
|
| 34 |
+
</div>
|
| 35 |
+
<button type="submit" id="loginBtn" class="inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-10 w-full disabled:opacity-50">登录</button>
|
| 36 |
+
</form>
|
| 37 |
+
<div class="mt-6 text-center text-xs text-muted-foreground">
|
| 38 |
+
<p>OB1 2API © 2025</p>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
<script>
|
| 44 |
+
const form=document.getElementById('loginForm'),btn=document.getElementById('loginBtn');
|
| 45 |
+
form.addEventListener('submit',async(e)=>{e.preventDefault();btn.disabled=true;btn.textContent='登录中...';try{const r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('username').value.trim(),password:document.getElementById('password').value})});const d=await r.json();d.success?(localStorage.setItem('adminToken',d.token),location.href='/static/manage.html'):showToast(d.message||'登录失败','error')}catch(e){showToast('网络错误','error')}finally{btn.disabled=false;btn.textContent='登录'}});
|
| 46 |
+
function showToast(m,t='error'){const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.remove(),300)},2000)}
|
| 47 |
+
window.addEventListener('DOMContentLoaded',()=>{const t=localStorage.getItem('adminToken');t&&fetch('/admin/status',{headers:{Authorization:'Bearer '+t}}).then(r=>{if(r.ok)location.href='/static/manage.html'})});
|
| 48 |
+
</script>
|
| 49 |
+
</body>
|
| 50 |
+
</html>
|
static/manage.css
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 2 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
| 3 |
+
.tab-btn{transition:all .2s ease}
|
| 4 |
+
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:.3}}
|
| 5 |
+
.typing-dot{animation:pulse-dot 1.2s infinite}
|
| 6 |
+
.typing-dot:nth-child(2){animation-delay:.2s}
|
| 7 |
+
.typing-dot:nth-child(3){animation-delay:.4s}
|
| 8 |
+
.chat-msg pre{background:#f3f4f6;border-radius:6px;padding:12px;overflow-x:auto;margin:8px 0}
|
| 9 |
+
.chat-msg code{font-size:13px}
|
| 10 |
+
.chat-msg p{margin:4px 0}
|
| 11 |
+
.chat-msg ul,.chat-msg ol{margin:4px 0 4px 20px}
|
| 12 |
+
|
| 13 |
+
/* Batch dropdown */
|
| 14 |
+
.batch-dropdown-container{position:relative;display:inline-block}
|
| 15 |
+
.batch-dropdown-btn{display:inline-flex;align-items:center;justify-content:center;height:32px;padding:0 12px;background:#6366f1;color:white;border:none;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s ease}
|
| 16 |
+
.batch-dropdown-btn:hover{background:#4f46e5}
|
| 17 |
+
.batch-dropdown-arrow{margin-left:4px;transition:transform .3s cubic-bezier(.4,0,.2,1)}
|
| 18 |
+
.batch-dropdown-container:hover .batch-dropdown-arrow{transform:rotate(180deg)}
|
| 19 |
+
.batch-dropdown-menu{position:absolute;top:calc(100% + 4px);left:0;min-width:160px;background:white;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.15);z-index:50;opacity:0;visibility:hidden;transform:translateY(-8px);transition:all .2s cubic-bezier(.4,0,.2,1);overflow:hidden}
|
| 20 |
+
.batch-dropdown-container:hover .batch-dropdown-menu{opacity:1;visibility:visible;transform:translateY(0)}
|
| 21 |
+
.batch-dropdown-item{display:flex;align-items:center;width:100%;padding:8px 12px;border:none;background:none;cursor:pointer;font-size:13px;color:#374151;transition:background .15s}
|
| 22 |
+
.batch-dropdown-item:hover{background:#f3f4f6}
|
| 23 |
+
.batch-dropdown-item svg{width:16px;height:16px;margin-right:8px;flex-shrink:0}
|
| 24 |
+
|
| 25 |
+
/* Modal */
|
| 26 |
+
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:flex;align-items:center;justify-content:center}
|
| 27 |
+
.modal-box{background:white;border-radius:8px;padding:24px;width:90%;max-width:480px;box-shadow:0 8px 30px rgba(0,0,0,.2)}
|
| 28 |
+
|
| 29 |
+
/* Account card */
|
| 30 |
+
.account-card{border:1px solid hsl(0 0% 89%);border-radius:8px;padding:16px;transition:box-shadow .2s}
|
| 31 |
+
.account-card:hover{box-shadow:0 2px 8px rgba(0,0,0,.08)}
|
| 32 |
+
.account-status{display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:2px 8px;border-radius:9999px}
|
| 33 |
+
.account-status.active{background:#dcfce7;color:#16a34a}
|
| 34 |
+
.account-status.expired{background:#fee2e2;color:#dc2626}
|
| 35 |
+
.account-status.unknown{background:#f3f4f6;color:#6b7280}
|
static/manage.html
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>管理控制台 - OB1 2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
| 9 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 11 |
+
<link rel="stylesheet" href="/static/manage.css">
|
| 12 |
+
<script>
|
| 13 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
| 14 |
+
</script>
|
| 15 |
+
</head>
|
| 16 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 17 |
+
<header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur">
|
| 18 |
+
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
|
| 19 |
+
<span class="font-bold text-xl mr-4">OB1 2API</span>
|
| 20 |
+
<div class="flex flex-1 items-center justify-end gap-3">
|
| 21 |
+
<a href="https://github.com/longnghiemduc6-art/ob12api" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors h-7 w-7 rounded" title="GitHub">
|
| 22 |
+
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
| 23 |
+
</a>
|
| 24 |
+
<button onclick="logout()" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-secondary h-7 px-2.5 gap-1 rounded">
|
| 25 |
+
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
| 26 |
+
退出
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</header>
|
| 31 |
+
|
| 32 |
+
<main class="mx-auto max-w-7xl px-6 py-6">
|
| 33 |
+
<div class="border-b border-border mb-6">
|
| 34 |
+
<nav class="flex space-x-8">
|
| 35 |
+
<button onclick="switchTab('accounts')" id="tabAccounts" class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">账号管理</button>
|
| 36 |
+
<button onclick="switchTab('settings')" id="tabSettings" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1 text-muted-foreground">系统设置</button>
|
| 37 |
+
<button onclick="switchTab('chat')" id="tabChat" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1 text-muted-foreground">聊天测试</button>
|
| 38 |
+
</nav>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- 账号管理 -->
|
| 42 |
+
<div id="panelAccounts">
|
| 43 |
+
<div class="grid gap-4 grid-cols-2 md:grid-cols-4 mb-6">
|
| 44 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 45 |
+
<p class="text-sm font-medium text-muted-foreground mb-1">OB1 账号</p>
|
| 46 |
+
<h3 class="text-2xl font-bold" id="statTotal">-</h3>
|
| 47 |
+
</div>
|
| 48 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 49 |
+
<p class="text-sm font-medium text-muted-foreground mb-1">活跃账号</p>
|
| 50 |
+
<h3 class="text-2xl font-bold text-green-600" id="statActive">-</h3>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 53 |
+
<p class="text-sm font-medium text-muted-foreground mb-1">总消耗</p>
|
| 54 |
+
<h3 class="text-2xl font-bold text-blue-600" id="statCost">$0.00</h3>
|
| 55 |
+
</div>
|
| 56 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 57 |
+
<p class="text-sm font-medium text-muted-foreground mb-1">总请求</p>
|
| 58 |
+
<h3 class="text-2xl font-bold text-purple-600" id="statRequests">-</h3>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="rounded-lg border border-border bg-background mb-6">
|
| 63 |
+
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
| 64 |
+
<div class="flex items-center gap-3">
|
| 65 |
+
<h3 class="text-lg font-semibold">OB1 账号</h3>
|
| 66 |
+
<select id="accountFilter" onchange="renderAccounts()" class="text-xs h-7 rounded-md border border-input bg-background px-2">
|
| 67 |
+
<option value="all">全部</option>
|
| 68 |
+
<option value="active">活跃</option>
|
| 69 |
+
<option value="expired">已过期</option>
|
| 70 |
+
</select>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="flex items-center gap-3">
|
| 73 |
+
<div class="flex items-center gap-2">
|
| 74 |
+
<span class="text-xs text-muted-foreground">自动刷新AT</span>
|
| 75 |
+
<label class="inline-flex items-center cursor-pointer">
|
| 76 |
+
<input type="checkbox" id="atAutoRefreshToggle" class="sr-only peer" checked>
|
| 77 |
+
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
| 78 |
+
</label>
|
| 79 |
+
</div>
|
| 80 |
+
<button onclick="loadAccounts()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-secondary h-8 w-8" title="刷新">
|
| 81 |
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
| 82 |
+
</button>
|
| 83 |
+
<div class="batch-dropdown-container">
|
| 84 |
+
<button class="batch-dropdown-btn">批量操作<svg class="batch-dropdown-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></button>
|
| 85 |
+
<div class="batch-dropdown-menu">
|
| 86 |
+
<button onclick="refreshAllAccounts()" class="batch-dropdown-item">
|
| 87 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
| 88 |
+
<span>全部刷新</span>
|
| 89 |
+
</button>
|
| 90 |
+
<button onclick="batchDeleteAccounts()" class="batch-dropdown-item" style="color:#dc2626">
|
| 91 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
| 92 |
+
<span>批量删除</span>
|
| 93 |
+
</button>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
<button onclick="exportAccounts()" class="inline-flex items-center justify-center rounded-md text-xs font-medium h-8 px-3 bg-blue-600 text-white hover:bg-blue-700 transition-colors">导出</button>
|
| 97 |
+
<button onclick="importAccounts()" class="inline-flex items-center justify-center rounded-md text-xs font-medium h-8 px-3 bg-green-600 text-white hover:bg-green-700 transition-colors">导入</button>
|
| 98 |
+
<button onclick="openDeviceAuth()" class="inline-flex items-center justify-center rounded-md text-xs font-medium h-8 px-3 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">添加账号</button>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="overflow-x-auto">
|
| 102 |
+
<table class="w-full text-sm">
|
| 103 |
+
<thead>
|
| 104 |
+
<tr class="border-b border-border text-left text-xs text-muted-foreground">
|
| 105 |
+
<th class="px-4 py-2 w-8"><input type="checkbox" id="selectAll" onchange="toggleSelectAll()" class="rounded"></th>
|
| 106 |
+
<th class="px-4 py-2 font-medium">邮箱</th>
|
| 107 |
+
<th class="px-4 py-2 font-medium">AT</th>
|
| 108 |
+
<th class="px-4 py-2 font-medium">RT</th>
|
| 109 |
+
<th class="px-4 py-2 font-medium">状态</th>
|
| 110 |
+
<th class="px-4 py-2 font-medium text-right">操作</th>
|
| 111 |
+
</tr>
|
| 112 |
+
</thead>
|
| 113 |
+
<tbody id="accountList"></tbody>
|
| 114 |
+
</table>
|
| 115 |
+
</div>
|
| 116 |
+
<div id="accountFooter" class="px-4 py-2.5 border-t border-border text-xs text-muted-foreground"></div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<!-- 系统设置 -->
|
| 121 |
+
<div id="panelSettings" class="hidden">
|
| 122 |
+
<div class="grid gap-6 lg:grid-cols-2">
|
| 123 |
+
<!-- 安全配置 -->
|
| 124 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 125 |
+
<h3 class="text-base font-semibold mb-4">安全配置</h3>
|
| 126 |
+
<div class="space-y-3">
|
| 127 |
+
<div>
|
| 128 |
+
<label class="text-sm text-muted-foreground">管理员用户名</label>
|
| 129 |
+
<div class="flex gap-2 mt-1">
|
| 130 |
+
<input id="cfgUsername" class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" placeholder="admin">
|
| 131 |
+
<button onclick="updateUsername()" class="shrink-0 h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90">保存</button>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
<div>
|
| 135 |
+
<label class="text-sm text-muted-foreground">修改密码</label>
|
| 136 |
+
<input id="cfgOldPwd" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm mt-1" placeholder="旧密码">
|
| 137 |
+
<input id="cfgNewPwd" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm mt-2" placeholder="新密码">
|
| 138 |
+
<button onclick="updatePassword()" class="mt-2 h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90">修改密码</button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
<!-- API 密钥配置 -->
|
| 143 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 144 |
+
<h3 class="text-base font-semibold mb-4">API 密钥配置</h3>
|
| 145 |
+
<div class="space-y-3">
|
| 146 |
+
<div>
|
| 147 |
+
<label class="text-sm text-muted-foreground">当前 API Key</label>
|
| 148 |
+
<input id="cfgCurrentKey" class="flex h-9 w-full rounded-md border border-input bg-secondary px-3 text-sm mt-1" readonly>
|
| 149 |
+
</div>
|
| 150 |
+
<div>
|
| 151 |
+
<label class="text-sm text-muted-foreground">新 API Key</label>
|
| 152 |
+
<div class="flex gap-2 mt-1">
|
| 153 |
+
<input id="cfgNewKey" class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" placeholder="输入新的 API Key">
|
| 154 |
+
<button onclick="updateAPIKey()" class="shrink-0 h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90">更新</button>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
<!-- 代理配置 -->
|
| 160 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 161 |
+
<h3 class="text-base font-semibold mb-4">代理配置</h3>
|
| 162 |
+
<div>
|
| 163 |
+
<label class="text-sm text-muted-foreground">代理 URL</label>
|
| 164 |
+
<div class="flex gap-2 mt-1">
|
| 165 |
+
<input id="cfgProxy" class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" placeholder="http://127.0.0.1:7890">
|
| 166 |
+
<button id="btnTestProxy" onclick="testProxy()" class="shrink-0 h-9 px-3 rounded-md border border-input bg-background text-sm hover:bg-secondary">测试</button>
|
| 167 |
+
<button onclick="updateProxy()" class="shrink-0 h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90">保存</button>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
<!-- 调度模式 -->
|
| 172 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 173 |
+
<h3 class="text-base font-semibold mb-4">调度模式</h3>
|
| 174 |
+
<div>
|
| 175 |
+
<label class="text-sm text-muted-foreground">选择调度策略</label>
|
| 176 |
+
<div class="flex gap-2 mt-1">
|
| 177 |
+
<select id="cfgRotationMode" onchange="selectRotation(this.value)" class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm">
|
| 178 |
+
<option value="cache-first">缓存优先 — 优先使用上次成功的账号</option>
|
| 179 |
+
<option value="balanced">平衡轮换 — 均衡分配请求负载</option>
|
| 180 |
+
<option value="performance">性能优先 — 随机选择,适合高并发</option>
|
| 181 |
+
</select>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
<!-- 自动刷新 -->
|
| 186 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 187 |
+
<h3 class="text-base font-semibold mb-4">Token 自动续期</h3>
|
| 188 |
+
<div>
|
| 189 |
+
<label class="text-sm text-muted-foreground">检查间隔(分钟),0 为关闭</label>
|
| 190 |
+
<div class="flex gap-2 mt-1">
|
| 191 |
+
<input id="cfgRefreshInterval" type="number" min="0" class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" placeholder="0">
|
| 192 |
+
<button onclick="updateRefreshInterval()" class="shrink-0 h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90">保存</button>
|
| 193 |
+
</div>
|
| 194 |
+
<p class="text-xs text-muted-foreground mt-1">定时检查 Token 有效期,仅在临近过期时才刷新</p>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
<!-- 调试日志 -->
|
| 198 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 199 |
+
<h3 class="text-base font-semibold mb-4">调试日志</h3>
|
| 200 |
+
<div class="flex items-center justify-between">
|
| 201 |
+
<span class="text-sm text-muted-foreground">开启后输出详细请求日志</span>
|
| 202 |
+
<label class="inline-flex items-center cursor-pointer">
|
| 203 |
+
<input type="checkbox" id="cfgDebugLog" onchange="toggleDebugLog()" class="sr-only peer">
|
| 204 |
+
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
| 205 |
+
</label>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<!-- 聊天测试 -->
|
| 212 |
+
<div id="panelChat" class="hidden">
|
| 213 |
+
<div class="rounded-lg border border-border bg-background">
|
| 214 |
+
<div class="flex items-center gap-3 p-4 border-b border-border">
|
| 215 |
+
<select id="chatModel" class="h-9 rounded-md border border-input bg-background px-3 text-sm">
|
| 216 |
+
<option value="">加载中...</option>
|
| 217 |
+
</select>
|
| 218 |
+
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="chatStream" checked class="rounded"> Stream</label>
|
| 219 |
+
<button onclick="clearChat()" class="ml-auto text-xs px-3 h-8 rounded-md border border-border hover:bg-secondary transition-colors">清空</button>
|
| 220 |
+
</div>
|
| 221 |
+
<div id="chatMessages" class="h-[calc(100vh-280px)] overflow-y-auto p-4 space-y-2"></div>
|
| 222 |
+
<div class="p-4 border-t border-border flex gap-2">
|
| 223 |
+
<textarea id="chatInput" rows="1" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none" placeholder="输入消息..."></textarea>
|
| 224 |
+
<button onclick="sendChat()" class="shrink-0 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90">发送</button>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</main>
|
| 229 |
+
|
| 230 |
+
<!-- Device Auth Modal -->
|
| 231 |
+
<div id="deviceAuthModal" class="modal-overlay hidden">
|
| 232 |
+
<div class="modal-box">
|
| 233 |
+
<h3 class="text-lg font-semibold mb-4">添加 OB1 账号</h3>
|
| 234 |
+
<div id="deviceAuthContent">
|
| 235 |
+
<p class="text-sm text-muted-foreground mb-4">点击下方按钮开始设备授权流程</p>
|
| 236 |
+
<button onclick="startDeviceAuth()" class="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90">开始授权</button>
|
| 237 |
+
</div>
|
| 238 |
+
<div id="deviceAuthPending" class="hidden text-center">
|
| 239 |
+
<p class="text-sm mb-2">请在浏览器中完成授权:</p>
|
| 240 |
+
<a id="deviceAuthLink" href="#" target="_blank" class="text-blue-600 text-sm underline break-all"></a>
|
| 241 |
+
<p class="text-xs text-muted-foreground mt-3">用户码: <span id="deviceAuthCode" class="font-mono font-bold"></span></p>
|
| 242 |
+
<div class="flex items-center justify-center gap-1 mt-4">
|
| 243 |
+
<span class="typing-dot w-2 h-2 rounded-full bg-primary"></span>
|
| 244 |
+
<span class="typing-dot w-2 h-2 rounded-full bg-primary"></span>
|
| 245 |
+
<span class="typing-dot w-2 h-2 rounded-full bg-primary"></span>
|
| 246 |
+
<span class="text-xs text-muted-foreground ml-2">等待授权...</span>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
<button onclick="closeDeviceAuth()" class="mt-4 text-xs text-muted-foreground hover:text-foreground">关闭</button>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<script src="/static/manage.js"></script>
|
| 254 |
+
</body>
|
| 255 |
+
</html>
|
static/manage.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ===== Auth ===== */
|
| 2 |
+
const TOKEN = localStorage.getItem('adminToken');
|
| 3 |
+
const api = (url, opts = {}) => {
|
| 4 |
+
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' };
|
| 5 |
+
return fetch(url, opts).then(r => { if (r.status === 401) { logout(); throw new Error('unauthorized'); } return r.json(); });
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
function checkAuth() {
|
| 9 |
+
if (!TOKEN) { location.href = '/static/login.html'; return; }
|
| 10 |
+
api('/admin/status').catch(() => logout());
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function logout() {
|
| 14 |
+
localStorage.removeItem('adminToken');
|
| 15 |
+
location.href = '/static/login.html';
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* ===== Toast ===== */
|
| 19 |
+
function showToast(msg, type = 'info') {
|
| 20 |
+
const colors = { success: 'bg-green-600', error: 'bg-red-600', info: 'bg-gray-900' };
|
| 21 |
+
const el = document.createElement('div');
|
| 22 |
+
el.className = `fixed bottom-4 right-4 ${colors[type] || colors.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-[200] animate-slide-up`;
|
| 23 |
+
el.textContent = msg;
|
| 24 |
+
document.body.appendChild(el);
|
| 25 |
+
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 300); }, 2000);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* ===== Tabs ===== */
|
| 29 |
+
function switchTab(tab) {
|
| 30 |
+
['accounts', 'settings', 'chat'].forEach(t => {
|
| 31 |
+
document.getElementById('panel' + t.charAt(0).toUpperCase() + t.slice(1)).classList.toggle('hidden', t !== tab);
|
| 32 |
+
const btn = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1));
|
| 33 |
+
btn.classList.toggle('border-primary', t === tab);
|
| 34 |
+
btn.classList.toggle('border-transparent', t !== tab);
|
| 35 |
+
btn.classList.toggle('text-muted-foreground', t !== tab);
|
| 36 |
+
});
|
| 37 |
+
if (tab === 'settings') loadSettings();
|
| 38 |
+
if (tab === 'accounts') loadAccounts();
|
| 39 |
+
if (tab === 'chat') loadChatModels();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* ===== Accounts ===== */
|
| 43 |
+
let _accounts = [];
|
| 44 |
+
|
| 45 |
+
function loadAccounts() {
|
| 46 |
+
api('/admin/accounts').then(d => {
|
| 47 |
+
_accounts = d.accounts || [];
|
| 48 |
+
const s = d.stats || {};
|
| 49 |
+
document.getElementById('statTotal').textContent = s.total ?? _accounts.length;
|
| 50 |
+
document.getElementById('statActive').textContent = s.active ?? '-';
|
| 51 |
+
document.getElementById('statCost').textContent = '$' + (s.cost ?? 0).toFixed(2);
|
| 52 |
+
document.getElementById('statRequests').textContent = s.requests ?? '-';
|
| 53 |
+
renderAccounts();
|
| 54 |
+
}).catch(() => showToast('加载账号失败', 'error'));
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
let _pageSize = 20;
|
| 58 |
+
let _currentPage = 1;
|
| 59 |
+
|
| 60 |
+
function renderAccounts() {
|
| 61 |
+
const box = document.getElementById('accountList');
|
| 62 |
+
const filter = document.getElementById('accountFilter').value;
|
| 63 |
+
const filtered = _accounts.map((a, i) => ({ ...a, _idx: i })).filter(a => {
|
| 64 |
+
if (filter === 'active') return a.active === true;
|
| 65 |
+
if (filter === 'expired') return a.active !== true;
|
| 66 |
+
return true;
|
| 67 |
+
});
|
| 68 |
+
const totalPages = Math.max(1, Math.ceil(filtered.length / _pageSize));
|
| 69 |
+
if (_currentPage > totalPages) _currentPage = totalPages;
|
| 70 |
+
const start = (_currentPage - 1) * _pageSize;
|
| 71 |
+
const paged = filtered.slice(start, start + _pageSize);
|
| 72 |
+
if (!filtered.length) {
|
| 73 |
+
box.innerHTML = `<tr><td colspan="6" class="text-center text-muted-foreground py-6 text-sm">${_accounts.length ? '无匹配账号' : '暂无账号,点击右上角添加'}</td></tr>`;
|
| 74 |
+
} else {
|
| 75 |
+
box.innerHTML = paged.map(a => {
|
| 76 |
+
const active = a.active === true;
|
| 77 |
+
const atTag = a.at_mask ? `<span class="text-green-600 font-mono text-xs">${a.at_mask}</span>` : '<span class="text-red-500">无</span>';
|
| 78 |
+
const rtTag = a.rt_mask ? `<span class="text-green-600 font-mono text-xs">${a.rt_mask}</span>` : '<span class="text-red-500">无</span>';
|
| 79 |
+
const statusHtml = active
|
| 80 |
+
? `<span class="inline-flex items-center gap-1.5 text-green-600"><span class="w-1.5 h-1.5 rounded-full bg-green-500"></span><span data-expires="${a.expires_at}"></span></span>`
|
| 81 |
+
: `<span class="inline-flex items-center gap-1.5 text-muted-foreground"><span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>已过期</span>`;
|
| 82 |
+
const checked = _selected.has(a._idx) ? 'checked' : '';
|
| 83 |
+
return `<tr class="border-b border-border hover:bg-secondary/50 transition-colors">
|
| 84 |
+
<td class="px-4 py-2.5 w-8"><input type="checkbox" data-idx="${a._idx}" ${checked} onchange="toggleSelect(${a._idx})" class="rounded"></td>
|
| 85 |
+
<td class="px-4 py-2.5 font-medium">${a.email || '未知邮箱'}</td>
|
| 86 |
+
<td class="px-4 py-2.5">${atTag}</td>
|
| 87 |
+
<td class="px-4 py-2.5">${rtTag}</td>
|
| 88 |
+
<td class="px-4 py-2.5">${statusHtml}</td>
|
| 89 |
+
<td class="px-4 py-2.5 text-right">
|
| 90 |
+
<button onclick="refreshAccount(${a._idx})" class="text-xs px-2 py-1 rounded border border-border hover:bg-secondary transition-colors">刷新</button>
|
| 91 |
+
<button onclick="removeAccount(${a._idx})" class="text-xs px-2 py-1 rounded border border-red-200 text-red-600 hover:bg-red-50 transition-colors ml-1">删除</button>
|
| 92 |
+
</td>
|
| 93 |
+
</tr>`;
|
| 94 |
+
}).join('');
|
| 95 |
+
}
|
| 96 |
+
const activeCount = _accounts.filter(a => a.active === true).length;
|
| 97 |
+
const selCount = _selected.size;
|
| 98 |
+
const selText = selCount ? `已选 ${selCount} | ` : '';
|
| 99 |
+
// pagination
|
| 100 |
+
let pageHtml = '';
|
| 101 |
+
if (totalPages > 1) {
|
| 102 |
+
pageHtml = `<button onclick="changePage(-1)" class="px-2 py-0.5 rounded border border-border hover:bg-secondary text-xs" ${_currentPage <= 1 ? 'disabled' : ''}><</button>
|
| 103 |
+
<span class="mx-1">${_currentPage}/${totalPages}</span>
|
| 104 |
+
<button onclick="changePage(1)" class="px-2 py-0.5 rounded border border-border hover:bg-secondary text-xs" ${_currentPage >= totalPages ? 'disabled' : ''}>></button>`;
|
| 105 |
+
}
|
| 106 |
+
document.getElementById('accountFooter').innerHTML = `<div class="flex items-center justify-between">
|
| 107 |
+
<span>${selText}显示 ${filtered.length} / ${_accounts.length} 个账号(活跃 ${activeCount})</span>
|
| 108 |
+
<div class="flex items-center gap-2">
|
| 109 |
+
${pageHtml}
|
| 110 |
+
<select onchange="changePageSize(this.value)" class="text-xs h-6 rounded border border-input bg-background px-1">
|
| 111 |
+
${[10,20,50,100,200,500,1000].map(n => `<option value="${n}" ${n===_pageSize?'selected':''}>${n}条/页</option>`).join('')}
|
| 112 |
+
</select>
|
| 113 |
+
</div>
|
| 114 |
+
</div>`;
|
| 115 |
+
document.getElementById('selectAll').checked = paged.length > 0 && paged.every(a => _selected.has(a._idx));
|
| 116 |
+
updateCountdowns();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
let _selected = new Set();
|
| 120 |
+
function toggleSelect(idx) {
|
| 121 |
+
_selected.has(idx) ? _selected.delete(idx) : _selected.add(idx);
|
| 122 |
+
renderAccounts();
|
| 123 |
+
}
|
| 124 |
+
function toggleSelectAll() {
|
| 125 |
+
const all = document.getElementById('selectAll').checked;
|
| 126 |
+
const filter = document.getElementById('accountFilter').value;
|
| 127 |
+
const filtered = _accounts.map((a, i) => ({ ...a, _idx: i })).filter(a => {
|
| 128 |
+
if (filter === 'active') return a.active === true;
|
| 129 |
+
if (filter === 'expired') return a.active !== true;
|
| 130 |
+
return true;
|
| 131 |
+
});
|
| 132 |
+
const start = (_currentPage - 1) * _pageSize;
|
| 133 |
+
const paged = filtered.slice(start, start + _pageSize);
|
| 134 |
+
paged.forEach(a => all ? _selected.add(a._idx) : _selected.delete(a._idx));
|
| 135 |
+
renderAccounts();
|
| 136 |
+
}
|
| 137 |
+
function getSelectedIndices() { return [..._selected]; }
|
| 138 |
+
function changePageSize(v) { _pageSize = parseInt(v); _currentPage = 1; renderAccounts(); }
|
| 139 |
+
function changePage(delta) { _currentPage += delta; renderAccounts(); }
|
| 140 |
+
|
| 141 |
+
function updateCountdowns() {
|
| 142 |
+
document.querySelectorAll('[data-expires]').forEach(el => {
|
| 143 |
+
const exp = parseInt(el.dataset.expires);
|
| 144 |
+
const diff = exp - Date.now();
|
| 145 |
+
if (diff <= 0) { el.textContent = '已过期'; el.className = 'text-red-500'; return; }
|
| 146 |
+
const d = Math.floor(diff / 86400000);
|
| 147 |
+
const h = Math.floor((diff % 86400000) / 3600000);
|
| 148 |
+
const m = Math.floor((diff % 3600000) / 60000);
|
| 149 |
+
let text = '';
|
| 150 |
+
if (d > 0) text += d + '天';
|
| 151 |
+
text += h + 'h ' + m + 'm';
|
| 152 |
+
el.textContent = text;
|
| 153 |
+
if (d < 1) el.className = 'text-orange-500';
|
| 154 |
+
});
|
| 155 |
+
}
|
| 156 |
+
if (!window._countdownTimer) window._countdownTimer = setInterval(updateCountdowns, 1000);
|
| 157 |
+
|
| 158 |
+
function refreshAccount(idx) {
|
| 159 |
+
api(`/admin/accounts/${idx}/refresh`, { method: 'POST' }).then(d => {
|
| 160 |
+
d.ok ? showToast('刷新成功', 'success') : showToast(d.error || '刷新失败', 'error');
|
| 161 |
+
loadAccounts();
|
| 162 |
+
});
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function removeAccount(idx) {
|
| 166 |
+
if (!confirm('确定删除该账号?')) return;
|
| 167 |
+
api(`/admin/accounts/${idx}`, { method: 'DELETE' }).then(() => { showToast('已删除', 'success'); loadAccounts(); });
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function refreshAllAccounts() {
|
| 171 |
+
api('/admin/refresh', { method: 'POST' }).then(d => {
|
| 172 |
+
d.ok ? showToast('全部刷新完成', 'success') : showToast('刷新失败', 'error');
|
| 173 |
+
loadAccounts();
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function batchDeleteAccounts() {
|
| 178 |
+
if (!confirm('确定删除所有账号?')) return;
|
| 179 |
+
const indices = _accounts.map((_, i) => i);
|
| 180 |
+
api('/admin/accounts/batch-delete', { method: 'POST', body: JSON.stringify({ indices }) }).then(d => {
|
| 181 |
+
showToast(`已删除 ${d.removed} 个账号`, 'success'); loadAccounts();
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function exportAccounts() {
|
| 186 |
+
api('/admin/accounts/export', { method: 'POST' }).then(d => {
|
| 187 |
+
const blob = new Blob([JSON.stringify(d.accounts, null, 2)], { type: 'application/json' });
|
| 188 |
+
const a = document.createElement('a');
|
| 189 |
+
a.href = URL.createObjectURL(blob); a.download = 'ob1_accounts.json'; a.click();
|
| 190 |
+
showToast('导出成功', 'success');
|
| 191 |
+
});
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function importAccounts() {
|
| 195 |
+
const input = document.createElement('input');
|
| 196 |
+
input.type = 'file'; input.accept = '.json';
|
| 197 |
+
input.onchange = e => {
|
| 198 |
+
const file = e.target.files[0]; if (!file) return;
|
| 199 |
+
const reader = new FileReader();
|
| 200 |
+
reader.onload = ev => {
|
| 201 |
+
try {
|
| 202 |
+
const accounts = JSON.parse(ev.target.result);
|
| 203 |
+
api('/admin/accounts/import', { method: 'POST', body: JSON.stringify({ accounts: Array.isArray(accounts) ? accounts : [accounts] }) })
|
| 204 |
+
.then(d => { showToast(`导入 ${d.imported} 个账号`, 'success'); loadAccounts(); });
|
| 205 |
+
} catch { showToast('JSON 格式错误', 'error'); }
|
| 206 |
+
};
|
| 207 |
+
reader.readAsText(file);
|
| 208 |
+
};
|
| 209 |
+
input.click();
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/* ===== Device Auth (Add Account) ===== */
|
| 213 |
+
let _pollTimer = null;
|
| 214 |
+
|
| 215 |
+
function openDeviceAuth() {
|
| 216 |
+
document.getElementById('deviceAuthModal').classList.remove('hidden');
|
| 217 |
+
document.getElementById('deviceAuthContent').classList.remove('hidden');
|
| 218 |
+
document.getElementById('deviceAuthPending').classList.add('hidden');
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function startDeviceAuth() {
|
| 222 |
+
document.getElementById('deviceAuthContent').classList.add('hidden');
|
| 223 |
+
document.getElementById('deviceAuthPending').classList.remove('hidden');
|
| 224 |
+
api('/admin/device-auth', { method: 'POST' }).then(d => {
|
| 225 |
+
if (d.error) {
|
| 226 |
+
document.getElementById('deviceAuthContent').classList.remove('hidden');
|
| 227 |
+
document.getElementById('deviceAuthPending').classList.add('hidden');
|
| 228 |
+
showToast(d.error, 'error');
|
| 229 |
+
return;
|
| 230 |
+
}
|
| 231 |
+
const link = document.getElementById('deviceAuthLink');
|
| 232 |
+
link.href = d.verification_uri_complete || d.verification_uri;
|
| 233 |
+
link.textContent = d.verification_uri_complete || d.verification_uri;
|
| 234 |
+
document.getElementById('deviceAuthCode').textContent = d.user_code || '';
|
| 235 |
+
pollDeviceAuth(d.device_code, d.interval || 5);
|
| 236 |
+
}).catch(() => {
|
| 237 |
+
document.getElementById('deviceAuthContent').classList.remove('hidden');
|
| 238 |
+
document.getElementById('deviceAuthPending').classList.add('hidden');
|
| 239 |
+
showToast('获取授权失败', 'error');
|
| 240 |
+
});
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function pollDeviceAuth(code, interval) {
|
| 244 |
+
clearInterval(_pollTimer);
|
| 245 |
+
_pollTimer = setInterval(() => {
|
| 246 |
+
api('/admin/device-auth/poll', { method: 'POST', body: JSON.stringify({ device_code: code }) }).then(d => {
|
| 247 |
+
if (d.status === 'complete') {
|
| 248 |
+
clearInterval(_pollTimer);
|
| 249 |
+
closeDeviceAuth();
|
| 250 |
+
showToast(`已添加账号: ${d.email}`, 'success');
|
| 251 |
+
loadAccounts();
|
| 252 |
+
} else if (d.status === 'expired' || d.status === 'error') {
|
| 253 |
+
clearInterval(_pollTimer);
|
| 254 |
+
showToast(d.message || '授权失败', 'error');
|
| 255 |
+
}
|
| 256 |
+
});
|
| 257 |
+
}, interval * 1000);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function closeDeviceAuth() {
|
| 261 |
+
clearInterval(_pollTimer);
|
| 262 |
+
document.getElementById('deviceAuthModal').classList.add('hidden');
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/* ===== Settings ===== */
|
| 266 |
+
function loadSettings() {
|
| 267 |
+
api('/admin/settings').then(d => {
|
| 268 |
+
document.getElementById('cfgUsername').value = d.username || '';
|
| 269 |
+
document.getElementById('cfgCurrentKey').value = d.api_key || '';
|
| 270 |
+
document.getElementById('cfgProxy').value = d.proxy_url || '';
|
| 271 |
+
selectRotation(d.rotation_mode || 'cache-first', false);
|
| 272 |
+
document.getElementById('cfgDebugLog').checked = (d.log_level || 'INFO') === 'DEBUG';
|
| 273 |
+
document.getElementById('cfgRefreshInterval').value = d.refresh_interval || 0;
|
| 274 |
+
});
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function updatePassword() {
|
| 278 |
+
const old_password = document.getElementById('cfgOldPwd').value;
|
| 279 |
+
const new_password = document.getElementById('cfgNewPwd').value;
|
| 280 |
+
if (!old_password || !new_password) { showToast('请填写完整', 'error'); return; }
|
| 281 |
+
api('/admin/settings/password', { method: 'POST', body: JSON.stringify({ old_password, new_password }) }).then(d => {
|
| 282 |
+
d.ok ? (showToast('密码已更新', 'success'), document.getElementById('cfgOldPwd').value = '', document.getElementById('cfgNewPwd').value = '') : showToast(d.message || '更新失败', 'error');
|
| 283 |
+
});
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function updateUsername() {
|
| 287 |
+
const username = document.getElementById('cfgUsername').value.trim();
|
| 288 |
+
if (!username) return;
|
| 289 |
+
api('/admin/settings/username', { method: 'POST', body: JSON.stringify({ username }) }).then(d => {
|
| 290 |
+
d.ok ? showToast('用户名已更新', 'success') : showToast('更新失败', 'error');
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function updateAPIKey() {
|
| 295 |
+
const api_key = document.getElementById('cfgNewKey').value.trim();
|
| 296 |
+
if (!api_key) return;
|
| 297 |
+
api('/admin/settings/api-key', { method: 'POST', body: JSON.stringify({ api_key }) }).then(d => {
|
| 298 |
+
d.ok ? showToast('API Key 已更新', 'success') : showToast('更新失败', 'error');
|
| 299 |
+
});
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function updateProxy() {
|
| 303 |
+
const url = document.getElementById('cfgProxy').value.trim();
|
| 304 |
+
api('/admin/settings/proxy', { method: 'POST', body: JSON.stringify({ url }) }).then(d => {
|
| 305 |
+
d.ok ? showToast('代理已更新', 'success') : showToast('更新失败', 'error');
|
| 306 |
+
});
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function testProxy() {
|
| 310 |
+
const url = document.getElementById('cfgProxy').value.trim();
|
| 311 |
+
if (!url) { showToast('请先填写代理地址', 'error'); return; }
|
| 312 |
+
const btn = document.getElementById('btnTestProxy');
|
| 313 |
+
btn.disabled = true; btn.textContent = '测试中...';
|
| 314 |
+
api('/admin/settings/proxy-test', { method: 'POST', body: JSON.stringify({ url }) }).then(d => {
|
| 315 |
+
if (d.ok) showToast('代理可用,IP: ' + d.ip, 'success');
|
| 316 |
+
else showToast('代理不可用: ' + d.error, 'error');
|
| 317 |
+
}).catch(() => showToast('测试请求失败', 'error'))
|
| 318 |
+
.finally(() => { btn.disabled = false; btn.textContent = '测试'; });
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function selectRotation(mode, save = true) {
|
| 322 |
+
document.getElementById('cfgRotationMode').value = mode;
|
| 323 |
+
if (save) {
|
| 324 |
+
api('/admin/settings/rotation-mode', { method: 'POST', body: JSON.stringify({ mode }) }).then(d => {
|
| 325 |
+
d.ok ? showToast('调度模式已更新', 'success') : showToast(d.message || '更新失败', 'error');
|
| 326 |
+
});
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function toggleDebugLog() {
|
| 331 |
+
const level = document.getElementById('cfgDebugLog').checked ? 'DEBUG' : 'INFO';
|
| 332 |
+
api('/admin/settings/log-level', { method: 'POST', body: JSON.stringify({ level }) }).then(d => {
|
| 333 |
+
d.ok ? showToast(level === 'DEBUG' ? '调试日志已开启' : '调试日志已关闭', 'success') : showToast(d.message || '更新失败', 'error');
|
| 334 |
+
});
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
function updateRefreshInterval() {
|
| 338 |
+
const interval = parseInt(document.getElementById('cfgRefreshInterval').value) || 0;
|
| 339 |
+
api('/admin/settings/refresh-interval', { method: 'POST', body: JSON.stringify({ interval }) }).then(d => {
|
| 340 |
+
d.ok ? showToast(interval > 0 ? `自动续期检查已设置为 ${interval} 分钟` : '自动续期已关闭', 'success') : showToast(d.message || '更新失败', 'error');
|
| 341 |
+
});
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/* ===== Chat ===== */
|
| 345 |
+
let _chatMessages = [];
|
| 346 |
+
|
| 347 |
+
const TOP_MODELS = [
|
| 348 |
+
'anthropic/claude-opus-4.6',
|
| 349 |
+
'anthropic/claude-sonnet-4.6',
|
| 350 |
+
'openai/gpt-5.4-pro',
|
| 351 |
+
'google/gemini-3.1-flash-image-preview',
|
| 352 |
+
'openai/gpt-5.3-codex',
|
| 353 |
+
'x-ai/grok-4.1-fast',
|
| 354 |
+
'qwen/qwen-3.5-397b',
|
| 355 |
+
];
|
| 356 |
+
|
| 357 |
+
function _shortName(id) { return id.includes('/') ? id.split('/').pop() : id; }
|
| 358 |
+
|
| 359 |
+
function loadChatModels() {
|
| 360 |
+
const sel = document.getElementById('chatModel');
|
| 361 |
+
const prev = sel.value;
|
| 362 |
+
fetch('/v1/models', { headers: { 'Authorization': 'Bearer ' + TOKEN } })
|
| 363 |
+
.then(r => r.json())
|
| 364 |
+
.then(d => {
|
| 365 |
+
const apiIds = (d.data || []).map(m => m.id);
|
| 366 |
+
_fillModelSelect(sel, apiIds, prev);
|
| 367 |
+
})
|
| 368 |
+
.catch(() => _fillModelSelect(sel, [], prev));
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
function _fillModelSelect(sel, apiIds, prev) {
|
| 372 |
+
const all = new Set([...TOP_MODELS, ...apiIds]);
|
| 373 |
+
const topSet = new Set(TOP_MODELS);
|
| 374 |
+
const rest = [...all].filter(id => !topSet.has(id)).sort();
|
| 375 |
+
let html = '<optgroup label="常用">';
|
| 376 |
+
html += TOP_MODELS.map(id => `<option value="${id}">${_shortName(id)}</option>`).join('');
|
| 377 |
+
html += '</optgroup>';
|
| 378 |
+
if (rest.length) {
|
| 379 |
+
html += '<optgroup label="全部">';
|
| 380 |
+
html += rest.map(id => `<option value="${id}">${_shortName(id)}</option>`).join('');
|
| 381 |
+
html += '</optgroup>';
|
| 382 |
+
}
|
| 383 |
+
sel.innerHTML = html;
|
| 384 |
+
sel.value = prev && all.has(prev) ? prev : TOP_MODELS[0];
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
function _parseAssistantMsg(msg) {
|
| 388 |
+
const content = msg.content;
|
| 389 |
+
const result = { role: 'assistant', content: '', images: [] };
|
| 390 |
+
if (typeof content === 'string') {
|
| 391 |
+
result.content = content || '';
|
| 392 |
+
} else if (Array.isArray(content)) {
|
| 393 |
+
for (const part of content) {
|
| 394 |
+
if (part.type === 'text') result.content += part.text || '';
|
| 395 |
+
else if (part.type === 'image_url') result.images.push(part.image_url?.url || '');
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
// OB1/OpenRouter puts images in message.images
|
| 399 |
+
if (Array.isArray(msg.images)) {
|
| 400 |
+
for (const img of msg.images) {
|
| 401 |
+
if (img.image_url?.url) result.images.push(img.image_url.url);
|
| 402 |
+
else if (typeof img === 'string') result.images.push(img);
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
if (!result.content && !result.images.length) result.content = 'No response';
|
| 406 |
+
return result;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function sendChat() {
|
| 410 |
+
const input = document.getElementById('chatInput');
|
| 411 |
+
const msg = input.value.trim();
|
| 412 |
+
if (!msg) return;
|
| 413 |
+
input.value = '';
|
| 414 |
+
_chatMessages.push({ role: 'user', content: msg });
|
| 415 |
+
renderChat();
|
| 416 |
+
|
| 417 |
+
const model = document.getElementById('chatModel').value;
|
| 418 |
+
const stream = document.getElementById('chatStream').checked;
|
| 419 |
+
|
| 420 |
+
if (stream) {
|
| 421 |
+
streamChat(model, msg);
|
| 422 |
+
} else {
|
| 423 |
+
api('/v1/chat/completions', {
|
| 424 |
+
method: 'POST',
|
| 425 |
+
body: JSON.stringify({ model, messages: _chatMessages, stream: false })
|
| 426 |
+
}).then(d => {
|
| 427 |
+
const msg = d.choices?.[0]?.message || {};
|
| 428 |
+
const parsed = _parseAssistantMsg(msg);
|
| 429 |
+
_chatMessages.push(parsed);
|
| 430 |
+
renderChat();
|
| 431 |
+
}).catch(() => {
|
| 432 |
+
_chatMessages.push({ role: 'assistant', content: '请求失败' });
|
| 433 |
+
renderChat();
|
| 434 |
+
});
|
| 435 |
+
}
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
function streamChat(model, msg) {
|
| 439 |
+
const assistantMsg = { role: 'assistant', content: '' };
|
| 440 |
+
_chatMessages.push(assistantMsg);
|
| 441 |
+
renderChat();
|
| 442 |
+
|
| 443 |
+
fetch('/v1/chat/completions', {
|
| 444 |
+
method: 'POST',
|
| 445 |
+
headers: { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' },
|
| 446 |
+
body: JSON.stringify({ model, messages: _chatMessages.slice(0, -1), stream: true })
|
| 447 |
+
}).then(resp => {
|
| 448 |
+
const reader = resp.body.getReader();
|
| 449 |
+
const decoder = new TextDecoder();
|
| 450 |
+
let buf = '';
|
| 451 |
+
function read() {
|
| 452 |
+
reader.read().then(({ done, value }) => {
|
| 453 |
+
if (done) { renderChat(); return; }
|
| 454 |
+
buf += decoder.decode(value, { stream: true });
|
| 455 |
+
const lines = buf.split('\n');
|
| 456 |
+
buf = lines.pop();
|
| 457 |
+
for (const line of lines) {
|
| 458 |
+
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
|
| 459 |
+
try {
|
| 460 |
+
const j = JSON.parse(line.slice(6));
|
| 461 |
+
const delta = j.choices?.[0]?.delta?.content;
|
| 462 |
+
if (delta) { assistantMsg.content += delta; renderChat(); }
|
| 463 |
+
} catch {}
|
| 464 |
+
}
|
| 465 |
+
read();
|
| 466 |
+
});
|
| 467 |
+
}
|
| 468 |
+
read();
|
| 469 |
+
}).catch(() => { assistantMsg.content = '流式请求失败'; renderChat(); });
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function renderChat() {
|
| 473 |
+
const box = document.getElementById('chatMessages');
|
| 474 |
+
box.innerHTML = _chatMessages.map(m => {
|
| 475 |
+
const isUser = m.role === 'user';
|
| 476 |
+
let rendered = typeof marked !== 'undefined' ? marked.parse(m.content || '') : (m.content || '');
|
| 477 |
+
if (m.images && m.images.length) {
|
| 478 |
+
rendered += m.images.map(url => `<img src="${url}" class="mt-2 max-w-full rounded-lg cursor-pointer" style="max-height:400px" onclick="window.open(this.src,'_blank')" alt="生成图片">`).join('');
|
| 479 |
+
}
|
| 480 |
+
return `<div class="flex ${isUser ? 'justify-end' : 'justify-start'} mb-3">
|
| 481 |
+
<div class="chat-msg max-w-[80%] rounded-lg px-4 py-2 text-sm ${isUser ? 'bg-primary text-primary-foreground' : 'bg-secondary'}">
|
| 482 |
+
${rendered}
|
| 483 |
+
</div>
|
| 484 |
+
</div>`;
|
| 485 |
+
}).join('');
|
| 486 |
+
box.scrollTop = box.scrollHeight;
|
| 487 |
+
document.querySelectorAll('.chat-msg pre code').forEach(el => hljs.highlightElement(el));
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
function clearChat() {
|
| 491 |
+
_chatMessages = [];
|
| 492 |
+
document.getElementById('chatMessages').innerHTML = '';
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
/* ===== Init ===== */
|
| 496 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 497 |
+
checkAuth();
|
| 498 |
+
loadAccounts();
|
| 499 |
+
document.getElementById('chatInput').addEventListener('keydown', e => {
|
| 500 |
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
|
| 501 |
+
});
|
| 502 |
+
});
|