david-baxter commited on
Commit
cef045d
·
verified ·
1 Parent(s): a020447

Upload 24 files

Browse files
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
- title: Ob12api
3
- emoji: 🐠
4
- colorFrom: blue
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- app_port: 8081
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ [![Star History Chart](https://api.star-history.com/svg?repos=longnghiemduc6-art/ob12api&type=Date)](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 &copy; 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' : ''}>&lt;</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' : ''}>&gt;</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
+ });