Spooker commited on
Commit
27d6aa6
·
verified ·
1 Parent(s): 555b83a

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +14 -0
  2. README.md +86 -0
  3. app.py +1185 -0
  4. requirements.txt +1 -0
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1
5
+
6
+ WORKDIR /app
7
+
8
+ COPY requirements.txt ./
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY app.py ./
12
+
13
+ EXPOSE 7860
14
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Freebuff OpenAI Proxy
3
+ emoji: 🚀
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Freebuff OpenAI Proxy for Hugging Face Spaces
12
+
13
+ 这是一个适配 **Hugging Face Docker Space** 的版本,保留了原项目的核心能力:
14
+
15
+ - `/v1/chat/completions`
16
+ - `/v1/responses`
17
+ - `/v1/models`
18
+ - `/v1/reset-run`
19
+ - `/health`
20
+ - 多账号轮询
21
+ - Agent Run 缓存
22
+ - 登录后自动追加到账号池
23
+
24
+ ## 部署方式
25
+
26
+ 1. 新建一个 **Docker Space**。
27
+ 2. 把本目录内的文件上传到 Space 根目录。
28
+ 3. 推荐在 Space Settings -> Secrets 中配置:
29
+ - `API_KEY`: 你的代理访问密钥(保护 `/v1/*`)
30
+ - `ADMIN_PASSWORD`: 管理页密码(保护网页登录与账号管理)
31
+ - `ACCOUNTS_JSON`: 可选,启动时预加载账号池
32
+ 4. 打开 Space 首页,使用网页管理页完成登录或检查状态。
33
+
34
+ ## 可选的 ACCOUNTS_JSON 格式
35
+
36
+ ```json
37
+ [
38
+ {
39
+ "name": "account-1",
40
+ "email": "a@example.com",
41
+ "authToken": "xxx"
42
+ },
43
+ {
44
+ "name": "account-2",
45
+ "email": "b@example.com",
46
+ "authToken": "yyy"
47
+ }
48
+ ]
49
+ ```
50
+
51
+ 也兼容原始的 `credentials.json` 结构:
52
+
53
+ ```json
54
+ {
55
+ "default": {
56
+ "name": "default",
57
+ "email": "a@example.com",
58
+ "authToken": "xxx"
59
+ },
60
+ "accounts": [
61
+ {
62
+ "name": "default",
63
+ "email": "a@example.com",
64
+ "authToken": "xxx"
65
+ }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ ## 持久化存储
71
+
72
+ 如果你的 Space 开启了持久化存储,程序会优先把凭据写入 `/data/manicode/credentials.json`。
73
+ 未开启时会写到容器内普通目录,Space 重启后会丢失。
74
+
75
+ ## 接口示例
76
+
77
+ ```bash
78
+ curl https://<your-space>.hf.space/v1/chat/completions \
79
+ -H "Authorization: Bearer <API_KEY>" \
80
+ -H "Content-Type: application/json" \
81
+ -d '{
82
+ "model": "minimax/minimax-m2.7",
83
+ "messages": [{"role": "user", "content": "你好"}],
84
+ "stream": false
85
+ }'
86
+ ```
app.py ADDED
@@ -0,0 +1,1185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Freebuff OpenAI API 反代代理 - Hugging Face Docker Space 版
4
+
5
+ 说明:
6
+ - 保留原有 OpenAI 兼容接口:
7
+ - /v1/chat/completions
8
+ - /v1/responses
9
+ - /v1/models
10
+ - /v1/reset-run
11
+ - /health
12
+ - 将原来的命令行/TTY 登录改为网页管理页:
13
+ - /
14
+ - /admin/login/start
15
+ - /admin/login/status
16
+ - 适配 Hugging Face Spaces:
17
+ - 读取 PORT 环境变量
18
+ - 凭据优先保存到 /data(若已开通持久化存储)
19
+ - 可通过 Secrets 传入 API_KEY / ADMIN_PASSWORD / ACCOUNTS_JSON
20
+ """
21
+
22
+ import asyncio
23
+ import json
24
+ import os
25
+ import platform
26
+ import random
27
+ import string
28
+ import sys
29
+ import time
30
+ import uuid
31
+ from pathlib import Path
32
+ from typing import Any, Dict, List, Optional, Tuple
33
+ from urllib.parse import quote
34
+
35
+ try:
36
+ import aiohttp
37
+ from aiohttp import web
38
+ except ImportError:
39
+ print("请先安装 aiohttp: pip install aiohttp")
40
+ sys.exit(1)
41
+
42
+ API_BASE = os.environ.get("API_BASE", "www.codebuff.com")
43
+ PORT = int(os.environ.get("PORT", "7860"))
44
+ HOST = os.environ.get("HOST", "0.0.0.0")
45
+ POLL_INTERVAL_S = int(os.environ.get("POLL_INTERVAL_S", "5"))
46
+ TIMEOUT_S = int(os.environ.get("TIMEOUT_S", "300"))
47
+
48
+ # /v1/* 访问鉴权(Bearer)
49
+ PROXY_API_KEY = os.environ.get("API_KEY", "")
50
+ # 管理后台鉴权(X-Admin-Password / ?password=)
51
+ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
52
+
53
+ MODEL_TO_AGENT = {
54
+ "minimax/minimax-m2.7": "base2-free",
55
+ "z-ai/glm-5.1": "base2-free",
56
+ "google/gemini-2.5-flash-lite": "file-picker",
57
+ "google/gemini-3.1-flash-lite-preview": "file-picker-max",
58
+ "google/gemini-3.1-pro-preview": "thinker-with-files-gemini",
59
+ }
60
+
61
+ default_model = os.environ.get("DEFAULT_MODEL", "minimax/minimax-m2.7")
62
+
63
+ # 多账号 Token 池
64
+ # token_pool 每项示例:
65
+ # {
66
+ # "id": "user-id",
67
+ # "name": "Alice",
68
+ # "email": "a@example.com",
69
+ # "authToken": "...",
70
+ # "credits": 0,
71
+ # }
72
+ token_pool: List[Dict[str, Any]] = []
73
+ next_token_index = 0
74
+
75
+ # Agent Run 缓存按 (token, agent_id) 维度维护
76
+ run_cache: Dict[Tuple[str, str], str] = {}
77
+
78
+ # 等待登录态缓存:{ login_id: {fingerprintId, fingerprintHash, expiresAt, loginUrl, createdAt} }
79
+ pending_logins: Dict[str, Dict[str, Any]] = {}
80
+
81
+ C = {
82
+ "R": "\033[0m", "B": "\033[1m", "G": "\033[32m",
83
+ "Y": "\033[33m", "E": "\033[31m", "C": "\033[36m", "D": "\033[90m",
84
+ }
85
+
86
+
87
+ def log(msg: str, t: str = "info") -> None:
88
+ c = {"success": C["G"], "error": C["E"], "warn": C["Y"]}.get(t, C["C"])
89
+ icon = {"success": "✓", "error": "✗", "warn": "⚠"}.get(t, "ℹ")
90
+ print(f"{c}{icon}{C['R']} {msg}", flush=True)
91
+
92
+
93
+ def token_fingerprint(auth_token: str) -> str:
94
+ if not auth_token:
95
+ return "none"
96
+ if len(auth_token) < 12:
97
+ return auth_token[:3] + "..."
98
+ return f"{auth_token[:6]}...{auth_token[-4:]}"
99
+
100
+
101
+ def generate_fingerprint_id() -> str:
102
+ chars = string.ascii_lowercase + string.digits
103
+ return f"codebuff-cli-{''.join(random.choices(chars, k=26))}"
104
+
105
+
106
+ def get_config_paths() -> Tuple[Path, Path]:
107
+ # Hugging Face Spaces 若启用了持久化存储,/data 会持久保存
108
+ if Path("/data").exists() and os.access("/data", os.W_OK):
109
+ config_dir = Path("/data") / "manicode"
110
+ return config_dir, config_dir / "credentials.json"
111
+
112
+ home = Path.home()
113
+ if platform.system() == "Windows":
114
+ config_dir = Path(os.environ.get("APPDATA", str(home))) / "manicode"
115
+ else:
116
+ config_dir = home / ".config" / "manicode"
117
+ return config_dir, config_dir / "credentials.json"
118
+
119
+
120
+ def normalize_accounts(creds: Any) -> List[Dict[str, Any]]:
121
+ """兼容旧格式(default)和新格式(accounts)。"""
122
+ if not isinstance(creds, dict):
123
+ return []
124
+
125
+ accounts: List[Dict[str, Any]] = []
126
+
127
+ raw_accounts = creds.get("accounts")
128
+ if isinstance(raw_accounts, list):
129
+ for entry in raw_accounts:
130
+ if isinstance(entry, dict) and entry.get("authToken"):
131
+ accounts.append({
132
+ "id": entry.get("id"),
133
+ "name": entry.get("name") or "unknown",
134
+ "email": entry.get("email") or "unknown",
135
+ "authToken": entry.get("authToken"),
136
+ "credits": entry.get("credits", 0),
137
+ })
138
+
139
+ default_entry = creds.get("default")
140
+ if isinstance(default_entry, dict) and default_entry.get("authToken"):
141
+ default_token = default_entry.get("authToken")
142
+ exists = any(acc.get("authToken") == default_token for acc in accounts)
143
+ if not exists:
144
+ accounts.insert(0, {
145
+ "id": default_entry.get("id"),
146
+ "name": default_entry.get("name") or "default",
147
+ "email": default_entry.get("email") or "unknown",
148
+ "authToken": default_token,
149
+ "credits": default_entry.get("credits", 0),
150
+ })
151
+
152
+ return accounts
153
+
154
+
155
+ def load_accounts() -> List[Dict[str, Any]]:
156
+ _, creds_path = get_config_paths()
157
+ disk_accounts: List[Dict[str, Any]] = []
158
+
159
+ if creds_path.exists():
160
+ try:
161
+ creds = json.loads(creds_path.read_text(encoding="utf-8"))
162
+ disk_accounts = normalize_accounts(creds)
163
+ except Exception as e:
164
+ log(f"读取本地凭据失败: {e}", "warn")
165
+
166
+ # 可选:通过 HF Secrets 直接注入账号池
167
+ env_accounts: List[Dict[str, Any]] = []
168
+ raw_env = os.environ.get("ACCOUNTS_JSON", "").strip()
169
+ if raw_env:
170
+ try:
171
+ parsed = json.loads(raw_env)
172
+ if isinstance(parsed, list):
173
+ env_accounts = [
174
+ {
175
+ "id": entry.get("id"),
176
+ "name": entry.get("name") or "unknown",
177
+ "email": entry.get("email") or "unknown",
178
+ "authToken": entry.get("authToken"),
179
+ "credits": entry.get("credits", 0),
180
+ }
181
+ for entry in parsed
182
+ if isinstance(entry, dict) and entry.get("authToken")
183
+ ]
184
+ elif isinstance(parsed, dict):
185
+ env_accounts = normalize_accounts(parsed)
186
+ except Exception as e:
187
+ log(f"解析 ACCOUNTS_JSON 失败: {e}", "warn")
188
+
189
+ merged: List[Dict[str, Any]] = []
190
+ seen = set()
191
+ for acc in disk_accounts + env_accounts:
192
+ token = acc.get("authToken")
193
+ if token and token not in seen:
194
+ seen.add(token)
195
+ merged.append(acc)
196
+ return merged
197
+
198
+
199
+ def save_accounts(accounts: List[Dict[str, Any]]) -> None:
200
+ config_dir, creds_path = get_config_paths()
201
+ config_dir.mkdir(parents=True, exist_ok=True)
202
+
203
+ if not accounts:
204
+ return
205
+
206
+ # default 指向当前首个账号,兼容旧逻辑
207
+ data = {
208
+ "default": {
209
+ "id": accounts[0].get("id"),
210
+ "name": accounts[0].get("name"),
211
+ "email": accounts[0].get("email"),
212
+ "authToken": accounts[0].get("authToken"),
213
+ "credits": accounts[0].get("credits", 0),
214
+ },
215
+ "accounts": accounts,
216
+ }
217
+ creds_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
218
+
219
+
220
+ async def warm_account_default_agent(session: aiohttp.ClientSession, account: Dict[str, Any]) -> Optional[str]:
221
+ auth_token = account.get("authToken")
222
+ if not auth_token:
223
+ return None
224
+ default_agent = MODEL_TO_AGENT.get(default_model, "base2-free")
225
+ try:
226
+ run_id = await create_agent_run(session, auth_token, default_agent)
227
+ run_cache[get_run_cache_key(auth_token, default_agent)] = run_id
228
+ return run_id
229
+ except Exception as e:
230
+ log(
231
+ f"预热失败: user={account.get('name')}, token={token_fingerprint(auth_token)}, err={e}",
232
+ "warn",
233
+ )
234
+ return None
235
+
236
+
237
+ def append_account(user_obj: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
238
+ """把新登录账号追加到 token 池并持久化(按 authToken 去重)。"""
239
+ global token_pool
240
+
241
+ new_acc = {
242
+ "id": user_obj.get("id"),
243
+ "name": user_obj.get("name") or "unknown",
244
+ "email": user_obj.get("email") or "unknown",
245
+ "authToken": user_obj.get("authToken") or user_obj.get("auth_token"),
246
+ "credits": user_obj.get("credits", 0),
247
+ }
248
+
249
+ if not new_acc["authToken"]:
250
+ raise RuntimeError("登录返回中缺少 authToken")
251
+
252
+ existing = {acc.get("authToken") for acc in token_pool}
253
+ if new_acc["authToken"] not in existing:
254
+ token_pool.append(new_acc)
255
+ save_accounts(token_pool)
256
+ return True, new_acc
257
+ return False, new_acc
258
+
259
+
260
+ def pick_next_account() -> Tuple[Dict[str, Any], int]:
261
+ """轮询选择下一个账号。"""
262
+ global next_token_index
263
+
264
+ if not token_pool:
265
+ raise RuntimeError("没有可用 token,请先在管理页添加至少一个账号")
266
+
267
+ idx = next_token_index % len(token_pool)
268
+ next_token_index = (next_token_index + 1) % len(token_pool)
269
+ return token_pool[idx], idx
270
+
271
+
272
+ def get_run_cache_key(auth_token: str, agent_id: str) -> Tuple[str, str]:
273
+ return auth_token, agent_id
274
+
275
+
276
+ # ============ HTTP 请求 ============
277
+
278
+ async def api_request(
279
+ session: aiohttp.ClientSession,
280
+ hostname: str,
281
+ path: str,
282
+ body: Optional[Dict[str, Any]] = None,
283
+ auth_token: Optional[str] = None,
284
+ method: str = "POST",
285
+ ) -> Dict[str, Any]:
286
+ url = f"https://{hostname}{path}"
287
+ headers = {
288
+ "Content-Type": "application/json",
289
+ "Accept": "application/json",
290
+ "User-Agent": "freebuff-proxy/1.0",
291
+ }
292
+ if auth_token:
293
+ headers["Authorization"] = f"Bearer {auth_token}"
294
+
295
+ kwargs: Dict[str, Any] = {
296
+ "headers": headers,
297
+ "timeout": aiohttp.ClientTimeout(total=30),
298
+ }
299
+ if body is not None and method.upper() != "GET":
300
+ kwargs["json"] = body
301
+
302
+ async with session.request(method.upper(), url, **kwargs) as resp:
303
+ try:
304
+ data = await resp.json()
305
+ except Exception:
306
+ data = await resp.text()
307
+ return {"status": resp.status, "data": data}
308
+
309
+
310
+ # ============ 登录流程(网页版) ============
311
+
312
+ async def start_login_flow(session: aiohttp.ClientSession) -> Dict[str, Any]:
313
+ fp_id = generate_fingerprint_id()
314
+ res = await api_request(session, "freebuff.com", "/api/auth/cli/code", {"fingerprintId": fp_id})
315
+ if res["status"] != 200 or "loginUrl" not in res["data"]:
316
+ raise RuntimeError(f"获取登录 URL 失败: {res['data']}")
317
+
318
+ d = res["data"]
319
+ login_id = uuid.uuid4().hex
320
+ pending_logins[login_id] = {
321
+ "fingerprintId": fp_id,
322
+ "fingerprintHash": d["fingerprintHash"],
323
+ "expiresAt": d["expiresAt"],
324
+ "loginUrl": d["loginUrl"],
325
+ "createdAt": time.time(),
326
+ }
327
+ return {
328
+ "loginId": login_id,
329
+ "fingerprintId": fp_id,
330
+ "fingerprintHash": d["fingerprintHash"],
331
+ "expiresAt": d["expiresAt"],
332
+ "loginUrl": d["loginUrl"],
333
+ }
334
+
335
+
336
+ async def poll_login_status(session: aiohttp.ClientSession, login_id: str) -> Dict[str, Any]:
337
+ pending = pending_logins.get(login_id)
338
+ if not pending:
339
+ raise RuntimeError("loginId 不存在或已过期")
340
+
341
+ # 清理超过 TIMEOUT_S 的登录任务
342
+ if time.time() - float(pending.get("createdAt", 0)) > TIMEOUT_S:
343
+ pending_logins.pop(login_id, None)
344
+ raise RuntimeError("登录已超时,请重新发起")
345
+
346
+ path = (
347
+ f"/api/auth/cli/status?fingerprintId={quote(str(pending['fingerprintId']))}"
348
+ f"&fingerprintHash={quote(str(pending['fingerprintHash']))}"
349
+ f"&expiresAt={quote(str(pending['expiresAt']))}"
350
+ )
351
+ sr = await api_request(session, "freebuff.com", path, method="GET")
352
+ if sr["status"] == 200 and isinstance(sr["data"], dict) and "user" in sr["data"]:
353
+ user = sr["data"]["user"]
354
+ added, new_acc = append_account(user)
355
+ pending_logins.pop(login_id, None)
356
+ await warm_account_default_agent(session, new_acc)
357
+ return {
358
+ "status": "success",
359
+ "added": added,
360
+ "user": {
361
+ "id": new_acc.get("id"),
362
+ "name": new_acc.get("name"),
363
+ "email": new_acc.get("email"),
364
+ "credits": new_acc.get("credits", 0),
365
+ "token": token_fingerprint(new_acc.get("authToken", "")),
366
+ },
367
+ }
368
+
369
+ return {
370
+ "status": "pending",
371
+ "message": "尚未完成登录,请在新标签页完成授权后继续轮询",
372
+ "upstream": sr.get("data"),
373
+ }
374
+
375
+
376
+ # ============ Freebuff API ============
377
+
378
+ async def create_agent_run(session: aiohttp.ClientSession, auth_token: str, agent_id: str) -> str:
379
+ t = time.time()
380
+ res = await api_request(
381
+ session,
382
+ API_BASE,
383
+ "/api/v1/agent-runs",
384
+ {"action": "START", "agentId": agent_id},
385
+ auth_token,
386
+ )
387
+ ms = int((time.time() - t) * 1000)
388
+ if res["status"] != 200 or "runId" not in res["data"]:
389
+ raise RuntimeError(f"创建 Agent Run 失败: {json.dumps(res['data'], ensure_ascii=False)}")
390
+ log(f"创建新 Agent Run: {res['data']['runId']} (耗时 {ms}ms, token={token_fingerprint(auth_token)})")
391
+ return res["data"]["runId"]
392
+
393
+
394
+ async def get_or_create_agent_run(session: aiohttp.ClientSession, auth_token: str, agent_id: str) -> str:
395
+ key = get_run_cache_key(auth_token, agent_id)
396
+ run_id = run_cache.get(key)
397
+ if run_id:
398
+ return run_id
399
+
400
+ run_id = await create_agent_run(session, auth_token, agent_id)
401
+ run_cache[key] = run_id
402
+ return run_id
403
+
404
+
405
+ async def finish_agent_run(session: aiohttp.ClientSession, auth_token: str, run_id: str) -> None:
406
+ await api_request(session, API_BASE, "/api/v1/agent-runs", {
407
+ "action": "FINISH", "runId": run_id, "status": "completed",
408
+ "totalSteps": 1, "directCredits": 0, "totalCredits": 0,
409
+ }, auth_token)
410
+
411
+
412
+ def make_freebuff_body(openai_body: Dict[str, Any], run_id: str) -> Dict[str, Any]:
413
+ body = dict(openai_body)
414
+ body["codebuff_metadata"] = {
415
+ "run_id": run_id,
416
+ "client_id": f"freebuff-proxy-{''.join(random.choices(string.ascii_lowercase + string.digits, k=8))}",
417
+ "cost_mode": "free",
418
+ }
419
+ return body
420
+
421
+
422
+ def build_openai_response(run_id: str, model: str, choice_data: Dict[str, Any], usage_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
423
+ choice = choice_data or {}
424
+ message = choice.get("message", {})
425
+ resp = {
426
+ "id": f"freebuff-{run_id}",
427
+ "object": "chat.completion",
428
+ "created": int(time.time()),
429
+ "model": model,
430
+ "choices": [{
431
+ "index": 0,
432
+ "message": {
433
+ "role": "assistant",
434
+ "content": message.get("content", ""),
435
+ },
436
+ "finish_reason": choice.get("finish_reason", "stop"),
437
+ }],
438
+ "usage": {
439
+ "prompt_tokens": (usage_data or {}).get("prompt_tokens", 0),
440
+ "completion_tokens": (usage_data or {}).get("completion_tokens", 0),
441
+ "total_tokens": (usage_data or {}).get("total_tokens", 0),
442
+ },
443
+ }
444
+ if message.get("tool_calls"):
445
+ resp["choices"][0]["message"]["tool_calls"] = message["tool_calls"]
446
+ return resp
447
+
448
+
449
+ # ============ 流式转发 ============
450
+
451
+ async def stream_to_openai_format(
452
+ session: aiohttp.ClientSession,
453
+ freebuff_body: Dict[str, Any],
454
+ auth_token: str,
455
+ response: web.StreamResponse,
456
+ model: str,
457
+ ) -> None:
458
+ url = f"https://{API_BASE}/api/v1/chat/completions"
459
+ headers = {
460
+ "Content-Type": "application/json",
461
+ "Authorization": f"Bearer {auth_token}",
462
+ "Accept": "text/event-stream",
463
+ "User-Agent": "freebuff-proxy/1.0",
464
+ }
465
+ response_id = f"freebuff-{int(time.time() * 1000)}"
466
+ finish_reason = "stop"
467
+
468
+ timeout = aiohttp.ClientTimeout(total=120)
469
+ async with session.post(url, json=freebuff_body, headers=headers, timeout=timeout) as resp:
470
+ if resp.status != 200:
471
+ err = await resp.text()
472
+ raise RuntimeError(f"HTTP {resp.status}: {err}")
473
+
474
+ buffer = ""
475
+ async for chunk in resp.content.iter_any():
476
+ buffer += chunk.decode("utf-8", errors="replace")
477
+ lines = buffer.split("\n")
478
+ buffer = lines.pop()
479
+
480
+ for line in lines:
481
+ trimmed = line.strip()
482
+ if not trimmed or not trimmed.startswith("data: "):
483
+ continue
484
+ json_str = trimmed[6:].strip()
485
+ if json_str == "[DONE]":
486
+ await response.write(b"data: [DONE]\n\n")
487
+ continue
488
+ try:
489
+ parsed = json.loads(json_str)
490
+ delta = (parsed.get("choices") or [{}])[0].get("delta", {})
491
+ cfr = (parsed.get("choices") or [{}])[0].get("finish_reason")
492
+ if cfr:
493
+ finish_reason = cfr
494
+
495
+ delta_obj: Dict[str, Any] = {}
496
+ if delta.get("content"):
497
+ delta_obj["content"] = delta["content"]
498
+ if delta.get("tool_calls"):
499
+ delta_obj["tool_calls"] = delta["tool_calls"]
500
+ if delta.get("role"):
501
+ delta_obj["role"] = delta["role"]
502
+
503
+ if delta_obj:
504
+ openai_chunk = {
505
+ "id": response_id,
506
+ "object": "chat.completion.chunk",
507
+ "created": int(time.time()),
508
+ "model": model,
509
+ "choices": [{"index": 0, "delta": delta_obj, "finish_reason": None}],
510
+ }
511
+ await response.write(f"data: {json.dumps(openai_chunk, ensure_ascii=False)}\n\n".encode())
512
+ except Exception:
513
+ pass
514
+
515
+ final_chunk = {
516
+ "id": response_id,
517
+ "object": "chat.completion.chunk",
518
+ "created": int(time.time()),
519
+ "model": model,
520
+ "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
521
+ }
522
+ await response.write(f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n".encode())
523
+ await response.write(b"data: [DONE]\n\n")
524
+ await response.write_eof()
525
+
526
+
527
+ # ============ 鉴权中间件 ============
528
+
529
+ @web.middleware
530
+ async def auth_middleware(request: web.Request, handler):
531
+ """当 PROXY_API_KEY 非空时,对 /v1/* 路由强制验证 Bearer token。"""
532
+ if PROXY_API_KEY and request.path.startswith("/v1/"):
533
+ auth_header = request.headers.get("Authorization", "")
534
+ token = auth_header[7:] if auth_header.startswith("Bearer ") else ""
535
+ if token != PROXY_API_KEY:
536
+ log(f"鉴权失败: path={request.path}, ip={request.remote}", "warn")
537
+ return web.json_response(
538
+ {"error": {"message": "Incorrect API key provided", "type": "invalid_request_error", "code": "invalid_api_key"}},
539
+ status=401,
540
+ )
541
+ return await handler(request)
542
+
543
+
544
+ def require_admin(request: web.Request) -> Optional[web.Response]:
545
+ if not ADMIN_PASSWORD:
546
+ return None
547
+ supplied = request.headers.get("X-Admin-Password") or request.query.get("password") or ""
548
+ if supplied != ADMIN_PASSWORD:
549
+ return web.json_response({"error": {"message": "Admin password required"}}, status=401)
550
+ return None
551
+
552
+
553
+ # ============ Responses API 辅助函数 ============
554
+
555
+
556
+ def _responses_parse_input(data: Dict[str, Any]) -> List[Dict[str, Any]]:
557
+ """将 Responses API 的 input 字段转换为 OpenAI messages 列表。"""
558
+ raw_input = data.get("input", "")
559
+ instructions = data.get("instructions", "")
560
+ messages: List[Dict[str, Any]] = []
561
+
562
+ if instructions:
563
+ messages.append({"role": "system", "content": instructions})
564
+
565
+ if isinstance(raw_input, str):
566
+ if raw_input:
567
+ messages.append({"role": "user", "content": raw_input})
568
+ elif isinstance(raw_input, list):
569
+ for item in raw_input:
570
+ if isinstance(item, str):
571
+ messages.append({"role": "user", "content": item})
572
+ elif isinstance(item, dict):
573
+ role = item.get("role", "user")
574
+ content = item.get("content", "")
575
+ item_type = item.get("type", "")
576
+ if item_type == "function_call_output":
577
+ messages.append({
578
+ "role": "tool",
579
+ "tool_call_id": item.get("call_id", ""),
580
+ "content": item.get("output", ""),
581
+ })
582
+ elif role in ("user", "assistant", "system", "developer"):
583
+ if role == "developer":
584
+ role = "system"
585
+ if isinstance(content, list):
586
+ text_parts: List[str] = []
587
+ for part in content:
588
+ if isinstance(part, dict):
589
+ if part.get("type") in ("input_text", "text"):
590
+ text_parts.append(part.get("text", ""))
591
+ elif isinstance(part, str):
592
+ text_parts.append(part)
593
+ content = "".join(text_parts)
594
+ messages.append({"role": role, "content": content})
595
+ return messages
596
+
597
+
598
+
599
+ def _responses_make_base(resp_id: str, model: str, created: float,
600
+ instructions: Optional[str] = None, status: str = "completed") -> Dict[str, Any]:
601
+ return {
602
+ "id": resp_id,
603
+ "object": "response",
604
+ "created_at": created,
605
+ "status": status,
606
+ "model": model,
607
+ "output": [],
608
+ "parallel_tool_calls": True,
609
+ "tool_choice": "auto",
610
+ "tools": [],
611
+ "temperature": 1.0,
612
+ "top_p": 1.0,
613
+ "max_output_tokens": None,
614
+ "truncation": "disabled",
615
+ "instructions": instructions,
616
+ "metadata": {},
617
+ "incomplete_details": None,
618
+ "error": None,
619
+ "usage": None,
620
+ }
621
+
622
+
623
+ # ============ 路由处理 ============
624
+
625
+ async def handle_chat_completion(request: web.Request) -> web.StreamResponse:
626
+ start = time.time()
627
+ session: aiohttp.ClientSession = request.app["client_session"]
628
+
629
+ try:
630
+ body = await request.json()
631
+ except Exception:
632
+ return web.json_response({"error": {"message": "Invalid JSON body"}}, status=400)
633
+
634
+ model = body.get("model", default_model)
635
+ agent_id = MODEL_TO_AGENT.get(model, "base2-free")
636
+
637
+ try:
638
+ account, account_idx = pick_next_account()
639
+ auth_token = account["authToken"]
640
+ except Exception as e:
641
+ return web.json_response({"error": {"message": str(e)}}, status=503)
642
+
643
+ log(
644
+ "收到请求: "
645
+ f"model={model}, messages={len(body.get('messages', []))}, stream={body.get('stream', False)}, "
646
+ f"account_index={account_idx}, user={account.get('name')}, token={token_fingerprint(auth_token)}"
647
+ )
648
+
649
+ try:
650
+ run_id = await get_or_create_agent_run(session, auth_token, agent_id)
651
+ except Exception as e:
652
+ return web.json_response({"error": {"message": str(e)}}, status=500)
653
+
654
+ fb_body = make_freebuff_body(body, run_id)
655
+
656
+ try:
657
+ if body.get("stream"):
658
+ response = web.StreamResponse(
659
+ status=200,
660
+ headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive"},
661
+ )
662
+ await response.prepare(request)
663
+ await stream_to_openai_format(session, fb_body, auth_token, response, model)
664
+ log(f"请求完成,总耗时 {int((time.time() - start) * 1000)}ms", "success")
665
+ return response
666
+
667
+ res = await api_request(session, API_BASE, "/api/v1/chat/completions", fb_body, auth_token)
668
+ if res["status"] == 200:
669
+ choice = (res["data"].get("choices") or [{}])[0]
670
+ resp = build_openai_response(run_id, model, choice, res["data"].get("usage"))
671
+ log(f"请求完成,总耗时 {int((time.time() - start) * 1000)}ms", "success")
672
+ return web.json_response(resp)
673
+ if res["status"] in (400, 404):
674
+ log("Agent Run 失效,重新创建...", "warn")
675
+ run_cache.pop(get_run_cache_key(auth_token, agent_id), None)
676
+ run_id = await get_or_create_agent_run(session, auth_token, agent_id)
677
+ fb_body["codebuff_metadata"]["run_id"] = run_id
678
+ retry = await api_request(session, API_BASE, "/api/v1/chat/completions", fb_body, auth_token)
679
+ if retry["status"] == 200:
680
+ choice = (retry["data"].get("choices") or [{}])[0]
681
+ resp = build_openai_response(run_id, model, choice, retry["data"].get("usage"))
682
+ log(f"重试成功,总耗时 {int((time.time() - start) * 1000)}ms", "success")
683
+ return web.json_response(resp)
684
+ return web.json_response({"error": {"message": retry["data"]}}, status=retry["status"])
685
+ return web.json_response({"error": {"message": res["data"]}}, status=res["status"])
686
+ except Exception as e:
687
+ log(f"请求失败: {e}", "error")
688
+ return web.json_response({"error": {"message": str(e)}}, status=500)
689
+
690
+
691
+ async def handle_responses(request: web.Request) -> web.StreamResponse:
692
+ """OpenAI Responses API 兼容端点 /v1/responses。"""
693
+ start = time.time()
694
+ session: aiohttp.ClientSession = request.app["client_session"]
695
+
696
+ try:
697
+ data = await request.json()
698
+ except Exception:
699
+ return web.json_response({"error": {"message": "Invalid JSON body"}}, status=400)
700
+
701
+ model = data.get("model", default_model)
702
+ stream = data.get("stream", False)
703
+ instructions = data.get("instructions")
704
+ agent_id = MODEL_TO_AGENT.get(model, "base2-free")
705
+
706
+ messages = _responses_parse_input(data)
707
+ if not messages:
708
+ return web.json_response({"error": {"message": "input is required", "type": "invalid_request_error"}}, status=400)
709
+
710
+ try:
711
+ account, account_idx = pick_next_account()
712
+ auth_token = account["authToken"]
713
+ except Exception as e:
714
+ return web.json_response({"error": {"message": str(e)}}, status=503)
715
+
716
+ log(
717
+ f"收到 Responses API 请求: model={model}, msgs={len(messages)}, stream={stream}, "
718
+ f"account_index={account_idx}, user={account.get('name')}, token={token_fingerprint(auth_token)}"
719
+ )
720
+
721
+ try:
722
+ run_id = await get_or_create_agent_run(session, auth_token, agent_id)
723
+ except Exception as e:
724
+ return web.json_response({"error": {"message": str(e)}}, status=500)
725
+
726
+ fb_body = make_freebuff_body({"model": model, "messages": messages, "stream": True}, run_id)
727
+
728
+ resp_id = f"resp_{int(time.time() * 1000)}"
729
+ msg_id = f"msg_{int(time.time() * 1000)}"
730
+ created = time.time()
731
+ msg_chars = sum(len(str(m.get("content", ""))) for m in messages)
732
+
733
+ url = f"https://{API_BASE}/api/v1/chat/completions"
734
+ headers = {
735
+ "Content-Type": "application/json",
736
+ "Authorization": f"Bearer {auth_token}",
737
+ "Accept": "text/event-stream",
738
+ "User-Agent": "freebuff-proxy/1.0",
739
+ }
740
+
741
+ try:
742
+ if stream:
743
+ response = web.StreamResponse(
744
+ status=200,
745
+ headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive"},
746
+ )
747
+ await response.prepare(request)
748
+
749
+ timeout = aiohttp.ClientTimeout(total=120)
750
+ async with session.post(url, json=fb_body, headers=headers, timeout=timeout) as upstream_resp:
751
+ if upstream_resp.status != 200:
752
+ err = await upstream_resp.text()
753
+ raise RuntimeError(f"HTTP {upstream_resp.status}: {err}")
754
+
755
+ seq = 0
756
+ full_text_parts: List[str] = []
757
+ base = _responses_make_base(resp_id, model, created, instructions, "in_progress")
758
+
759
+ async def emit(event_type: str, payload: Dict[str, Any]) -> None:
760
+ nonlocal seq
761
+ data_str = json.dumps({"type": event_type, "sequence_number": seq, **payload}, ensure_ascii=False)
762
+ await response.write(f"event: {event_type}\ndata: {data_str}\n\n".encode())
763
+ seq += 1
764
+
765
+ await emit("response.created", {"response": base})
766
+ await emit("response.in_progress", {"response": base})
767
+
768
+ item_skeleton = {"id": msg_id, "type": "message", "role": "assistant", "status": "in_progress", "content": []}
769
+ await emit("response.output_item.added", {"output_index": 0, "item": item_skeleton})
770
+
771
+ part_skeleton = {"type": "output_text", "text": "", "annotations": []}
772
+ await emit("response.content_part.added", {"item_id": msg_id, "output_index": 0, "content_index": 0, "part": part_skeleton})
773
+
774
+ buffer = ""
775
+ async for chunk in upstream_resp.content.iter_any():
776
+ buffer += chunk.decode("utf-8", errors="replace")
777
+ lines = buffer.split("\n")
778
+ buffer = lines.pop()
779
+ for line in lines:
780
+ trimmed = line.strip()
781
+ if not trimmed or not trimmed.startswith("data: "):
782
+ continue
783
+ json_str = trimmed[6:].strip()
784
+ if json_str == "[DONE]":
785
+ continue
786
+ try:
787
+ parsed = json.loads(json_str)
788
+ delta_content = (parsed.get("choices") or [{}])[0].get("delta", {}).get("content", "")
789
+ if delta_content:
790
+ full_text_parts.append(delta_content)
791
+ await emit("response.output_text.delta", {
792
+ "item_id": msg_id,
793
+ "output_index": 0,
794
+ "content_index": 0,
795
+ "delta": delta_content,
796
+ })
797
+ except Exception:
798
+ pass
799
+
800
+ full_text = "".join(full_text_parts)
801
+
802
+ await emit("response.output_text.done", {
803
+ "item_id": msg_id,
804
+ "output_index": 0,
805
+ "content_index": 0,
806
+ "text": full_text,
807
+ })
808
+
809
+ done_part = {"type": "output_text", "text": full_text, "annotations": []}
810
+ await emit("response.content_part.done", {
811
+ "item_id": msg_id,
812
+ "output_index": 0,
813
+ "content_index": 0,
814
+ "part": done_part,
815
+ })
816
+
817
+ done_item = {"id": msg_id, "type": "message", "role": "assistant", "status": "completed", "content": [done_part]}
818
+ await emit("response.output_item.done", {"output_index": 0, "item": done_item})
819
+
820
+ usage = {
821
+ "input_tokens": msg_chars // 4,
822
+ "input_tokens_details": {"cached_tokens": 0},
823
+ "output_tokens": len(full_text) // 4,
824
+ "output_tokens_details": {"reasoning_tokens": 0},
825
+ "total_tokens": (msg_chars + len(full_text)) // 4,
826
+ }
827
+ final = _responses_make_base(resp_id, model, created, instructions, "completed")
828
+ final["output"] = [done_item]
829
+ final["usage"] = usage
830
+ await emit("response.completed", {"response": final})
831
+
832
+ await response.write_eof()
833
+ log(f"Responses API 请求完成,总耗时 {int((time.time() - start) * 1000)}ms", "success")
834
+ return response
835
+
836
+ timeout = aiohttp.ClientTimeout(total=120)
837
+ content_parts: List[str] = []
838
+ async with session.post(url, json=fb_body, headers=headers, timeout=timeout) as upstream_resp:
839
+ if upstream_resp.status != 200:
840
+ err = await upstream_resp.text()
841
+ if upstream_resp.status in (400, 404):
842
+ log("Responses API: Agent Run 失效,重新创建...", "warn")
843
+ run_cache.pop(get_run_cache_key(auth_token, agent_id), None)
844
+ run_id = await get_or_create_agent_run(session, auth_token, agent_id)
845
+ fb_body["codebuff_metadata"]["run_id"] = run_id
846
+ async with session.post(url, json=fb_body, headers=headers, timeout=timeout) as retry_resp:
847
+ if retry_resp.status != 200:
848
+ raise RuntimeError(f"HTTP {retry_resp.status}: {await retry_resp.text()}")
849
+ buffer = ""
850
+ async for chunk in retry_resp.content.iter_any():
851
+ buffer += chunk.decode("utf-8", errors="replace")
852
+ for line in buffer.split("\n"):
853
+ trimmed = line.strip()
854
+ if not trimmed or not trimmed.startswith("data: "):
855
+ continue
856
+ json_str = trimmed[6:].strip()
857
+ if json_str == "[DONE]":
858
+ continue
859
+ try:
860
+ parsed = json.loads(json_str)
861
+ c = (parsed.get("choices") or [{}])[0].get("delta", {}).get("content", "")
862
+ if c:
863
+ content_parts.append(c)
864
+ except Exception:
865
+ pass
866
+ else:
867
+ raise RuntimeError(f"HTTP {upstream_resp.status}: {err}")
868
+ else:
869
+ buffer = ""
870
+ async for chunk in upstream_resp.content.iter_any():
871
+ buffer += chunk.decode("utf-8", errors="replace")
872
+ for line in buffer.split("\n"):
873
+ trimmed = line.strip()
874
+ if not trimmed or not trimmed.startswith("data: "):
875
+ continue
876
+ json_str = trimmed[6:].strip()
877
+ if json_str == "[DONE]":
878
+ continue
879
+ try:
880
+ parsed = json.loads(json_str)
881
+ c = (parsed.get("choices") or [{}])[0].get("delta", {}).get("content", "")
882
+ if c:
883
+ content_parts.append(c)
884
+ except Exception:
885
+ pass
886
+
887
+ full_text = "".join(content_parts)
888
+ output_item = {
889
+ "id": msg_id, "type": "message", "role": "assistant", "status": "completed",
890
+ "content": [{"type": "output_text", "text": full_text, "annotations": []}],
891
+ }
892
+ usage = {
893
+ "input_tokens": msg_chars // 4,
894
+ "input_tokens_details": {"cached_tokens": 0},
895
+ "output_tokens": len(full_text) // 4,
896
+ "output_tokens_details": {"reasoning_tokens": 0},
897
+ "total_tokens": (msg_chars + len(full_text)) // 4,
898
+ }
899
+ result = _responses_make_base(resp_id, model, created, instructions, "completed")
900
+ result["output"] = [output_item]
901
+ result["usage"] = usage
902
+ log(f"Responses API 请求完成,总耗时 {int((time.time() - start) * 1000)}ms", "success")
903
+ return web.json_response(result)
904
+
905
+ except Exception as e:
906
+ log(f"Responses API 请求失败: {e}", "error")
907
+ return web.json_response({"error": {"message": str(e)}}, status=500)
908
+
909
+
910
+ async def handle_models(request: web.Request) -> web.Response:
911
+ models = [{"id": m, "object": "model", "created": 1700000000, "owned_by": "freebuff"} for m in MODEL_TO_AGENT]
912
+ return web.json_response({"object": "list", "data": models})
913
+
914
+
915
+ async def handle_reset_run(request: web.Request) -> web.Response:
916
+ run_cache.clear()
917
+ log("Agent Run 缓存已清除")
918
+ return web.json_response({"status": "cleared"})
919
+
920
+
921
+ async def handle_health(request: web.Request) -> web.Response:
922
+ config_dir, creds_path = get_config_paths()
923
+ account_brief = [
924
+ {
925
+ "index": i,
926
+ "name": acc.get("name"),
927
+ "email": acc.get("email"),
928
+ "token": token_fingerprint(acc.get("authToken", "")),
929
+ }
930
+ for i, acc in enumerate(token_pool)
931
+ ]
932
+ return web.json_response({
933
+ "status": "ok",
934
+ "model": default_model,
935
+ "accounts": account_brief,
936
+ "accountCount": len(token_pool),
937
+ "nextAccountIndex": next_token_index,
938
+ "cachedRunCount": len(run_cache),
939
+ "storage": {
940
+ "configDir": str(config_dir),
941
+ "credentialsFile": str(creds_path),
942
+ "persistentDataMounted": Path("/data").exists(),
943
+ },
944
+ "security": {
945
+ "apiKeyEnabled": bool(PROXY_API_KEY),
946
+ "adminPasswordEnabled": bool(ADMIN_PASSWORD),
947
+ },
948
+ })
949
+
950
+
951
+ async def handle_root(request: web.Request) -> web.Response:
952
+ html = f"""<!doctype html>
953
+ <html lang="zh-CN">
954
+ <head>
955
+ <meta charset="utf-8" />
956
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
957
+ <title>Freebuff OpenAI Proxy - HF Space</title>
958
+ <style>
959
+ body {{ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; margin: 0; background: #0b1020; color: #e8eefc; }}
960
+ .wrap {{ max-width: 1100px; margin: 0 auto; padding: 28px; }}
961
+ .card {{ background: #141b34; border: 1px solid #2a355e; border-radius: 16px; padding: 18px; margin-bottom: 18px; }}
962
+ .row {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }}
963
+ input, button, textarea {{ width: 100%; box-sizing: border-box; border-radius: 12px; border: 1px solid #3a4b80; background: #0d1430; color: #fff; padding: 12px; }}
964
+ button {{ cursor: pointer; background: #3451ff; border: none; font-weight: 700; }}
965
+ button.secondary {{ background: #273053; }}
966
+ a {{ color: #94c7ff; }}
967
+ code, pre {{ background: #091024; color: #d6e6ff; border-radius: 10px; }}
968
+ pre {{ padding: 14px; overflow: auto; white-space: pre-wrap; }}
969
+ .muted {{ color: #9fb0dd; }}
970
+ .ok {{ color: #8dffb2; }}
971
+ .warn {{ color: #ffd37a; }}
972
+ </style>
973
+ </head>
974
+ <body>
975
+ <div class="wrap">
976
+ <div class="card">
977
+ <h1>Freebuff OpenAI Proxy · Hugging Face Space</h1>
978
+ <p class="muted">保留原脚本的 OpenAI 兼容接口、Responses API、账号池轮询、Run 缓存、健康检查与重置能力,并把登录迁移到网页管理页。</p>
979
+ <div class="row">
980
+ <div>
981
+ <strong>聊天接口</strong>
982
+ <pre>POST /v1/chat/completions</pre>
983
+ </div>
984
+ <div>
985
+ <strong>Responses API</strong>
986
+ <pre>POST /v1/responses</pre>
987
+ </div>
988
+ <div>
989
+ <strong>模型列表 / 健康检查</strong>
990
+ <pre>GET /v1/models
991
+ GET /health</pre>
992
+ </div>
993
+ </div>
994
+ </div>
995
+
996
+ <div class="card">
997
+ <h2>管理权限</h2>
998
+ <p class="muted">如果你设置了 <code>ADMIN_PASSWORD</code>,请先输入。它只用于管理页,不影响 /v1 接口。/v1 接口仍由 <code>API_KEY</code> 控制。</p>
999
+ <input id="adminPassword" type="password" placeholder="ADMIN_PASSWORD(可留空)" />
1000
+ </div>
1001
+
1002
+ <div class="card">
1003
+ <h2>账号管理</h2>
1004
+ <div class="row">
1005
+ <div><button onclick="startLogin()">1. 发起网页登录</button></div>
1006
+ <div><button class="secondary" onclick="checkLogin()">2. 检查登录状态</button></div>
1007
+ <div><button class="secondary" onclick="refreshHealth()">刷新状态</button></div>
1008
+ </div>
1009
+ <p class="muted">登录后账号会写入凭据文件。若 Space ��载了持久化存储,会写入 <code>/data/manicode/credentials.json</code>。</p>
1010
+ <pre id="loginBox">尚未发起登录。</pre>
1011
+ </div>
1012
+
1013
+ <div class="card">
1014
+ <h2>当前状态</h2>
1015
+ <pre id="healthBox">加载中...</pre>
1016
+ </div>
1017
+
1018
+ <div class="card">
1019
+ <h2>调用示例</h2>
1020
+ <pre id="curlBox">curl {(' -H "Authorization: Bearer ' + PROXY_API_KEY + '"') if PROXY_API_KEY else ''} \
1021
+ -H "Content-Type: application/json" \
1022
+ -X POST "./v1/chat/completions" \
1023
+ -d '{{
1024
+ "model": "{default_model}",
1025
+ "messages": [{{"role": "user", "content": "你好"}}],
1026
+ "stream": false
1027
+ }}'</pre>
1028
+ <p class="muted">在浏览器中用相对路径展示;实际接入请替换成你的 Space 域名。</p>
1029
+ </div>
1030
+ </div>
1031
+
1032
+ <script>
1033
+ let currentLoginId = localStorage.getItem('loginId') || '';
1034
+ const loginBox = document.getElementById('loginBox');
1035
+ const healthBox = document.getElementById('healthBox');
1036
+ const adminPassword = document.getElementById('adminPassword');
1037
+ adminPassword.value = localStorage.getItem('adminPassword') || '';
1038
+ adminPassword.addEventListener('input', () => localStorage.setItem('adminPassword', adminPassword.value));
1039
+
1040
+ function adminHeaders() {{
1041
+ const headers = {{ 'Content-Type': 'application/json' }};
1042
+ if (adminPassword.value) headers['X-Admin-Password'] = adminPassword.value;
1043
+ return headers;
1044
+ }}
1045
+
1046
+ async function refreshHealth() {{
1047
+ const res = await fetch('/health');
1048
+ const data = await res.json();
1049
+ healthBox.textContent = JSON.stringify(data, null, 2);
1050
+ }}
1051
+
1052
+ async function startLogin() {{
1053
+ const res = await fetch('/admin/login/start', {{ method: 'POST', headers: adminHeaders() }});
1054
+ const data = await res.json();
1055
+ if (!res.ok) {{
1056
+ loginBox.textContent = JSON.stringify(data, null, 2);
1057
+ return;
1058
+ }}
1059
+ currentLoginId = data.loginId;
1060
+ localStorage.setItem('loginId', currentLoginId);
1061
+ loginBox.innerHTML = `请在新标签页完成授权:\n\n${{JSON.stringify(data, null, 2)}}\n\n打开登录链接:\n${{data.loginUrl}}`;
1062
+ window.open(data.loginUrl, '_blank');
1063
+ }}
1064
+
1065
+ async function checkLogin() {{
1066
+ if (!currentLoginId) {{
1067
+ loginBox.textContent = '请先发起登录。';
1068
+ return;
1069
+ }}
1070
+ const url = '/admin/login/status?loginId=' + encodeURIComponent(currentLoginId) + (adminPassword.value ? ('&password=' + encodeURIComponent(adminPassword.value)) : '');
1071
+ const res = await fetch(url, {{ headers: adminHeaders() }});
1072
+ const data = await res.json();
1073
+ loginBox.textContent = JSON.stringify(data, null, 2);
1074
+ await refreshHealth();
1075
+ }}
1076
+
1077
+ refreshHealth();
1078
+ </script>
1079
+ </body>
1080
+ </html>
1081
+ """
1082
+ return web.Response(text=html, content_type="text/html")
1083
+
1084
+
1085
+ async def handle_admin_login_start(request: web.Request) -> web.Response:
1086
+ denied = require_admin(request)
1087
+ if denied:
1088
+ return denied
1089
+ session: aiohttp.ClientSession = request.app["client_session"]
1090
+ try:
1091
+ payload = await start_login_flow(session)
1092
+ return web.json_response(payload)
1093
+ except Exception as e:
1094
+ return web.json_response({"error": {"message": str(e)}}, status=500)
1095
+
1096
+
1097
+ async def handle_admin_login_status(request: web.Request) -> web.Response:
1098
+ denied = require_admin(request)
1099
+ if denied:
1100
+ return denied
1101
+ session: aiohttp.ClientSession = request.app["client_session"]
1102
+ login_id = request.query.get("loginId", "")
1103
+ if not login_id:
1104
+ return web.json_response({"error": {"message": "loginId is required"}}, status=400)
1105
+ try:
1106
+ payload = await poll_login_status(session, login_id)
1107
+ return web.json_response(payload)
1108
+ except Exception as e:
1109
+ return web.json_response({"error": {"message": str(e)}}, status=400)
1110
+
1111
+
1112
+ # ============ App 生命周期 ============
1113
+
1114
+ async def on_startup(app: web.Application) -> None:
1115
+ global token_pool
1116
+ session = aiohttp.ClientSession()
1117
+ app["client_session"] = session
1118
+
1119
+ token_pool = load_accounts()
1120
+ if token_pool:
1121
+ log(f"已加载账号池: {len(token_pool)} 个")
1122
+ for i, acc in enumerate(token_pool):
1123
+ log(
1124
+ f" [{i}] {acc.get('name')} <{acc.get('email')}> token={token_fingerprint(acc.get('authToken', ''))}"
1125
+ )
1126
+
1127
+ log("预热:为账号池创建默认 Agent Run...")
1128
+ warmed = 0
1129
+ for acc in token_pool:
1130
+ run_id = await warm_account_default_agent(session, acc)
1131
+ if run_id:
1132
+ warmed += 1
1133
+
1134
+ if warmed:
1135
+ log(f"预热完成,已缓存 {warmed} 个默认 Agent Run", "success")
1136
+ else:
1137
+ log("当前没有已预热账号;可在管理页登录新增账号", "warn")
1138
+
1139
+
1140
+ async def on_cleanup(app: web.Application) -> None:
1141
+ session: aiohttp.ClientSession = app["client_session"]
1142
+ if run_cache:
1143
+ log("关闭代理,结束全部缓存 Agent Run...")
1144
+ for (auth_token, _agent_id), run_id in list(run_cache.items()):
1145
+ try:
1146
+ await finish_agent_run(session, auth_token, run_id)
1147
+ except Exception:
1148
+ pass
1149
+ log("Agent Run 清理完成", "success")
1150
+ await session.close()
1151
+
1152
+
1153
+
1154
+ def create_app() -> web.Application:
1155
+ app = web.Application(middlewares=[auth_middleware], client_max_size=20 * 1024 * 1024)
1156
+ app.on_startup.append(on_startup)
1157
+ app.on_cleanup.append(on_cleanup)
1158
+
1159
+ app.router.add_get("/", handle_root)
1160
+ app.router.add_get("/health", handle_health)
1161
+ app.router.add_post("/v1/chat/completions", handle_chat_completion)
1162
+ app.router.add_post("/v1/responses", handle_responses)
1163
+ app.router.add_get("/v1/models", handle_models)
1164
+ app.router.add_post("/v1/reset-run", handle_reset_run)
1165
+ app.router.add_post("/admin/login/start", handle_admin_login_start)
1166
+ app.router.add_get("/admin/login/status", handle_admin_login_status)
1167
+ return app
1168
+
1169
+
1170
+ if __name__ == "__main__":
1171
+ app = create_app()
1172
+ log(f"代理地址: http://{HOST}:{PORT}/v1/chat/completions")
1173
+ log(f"Responses: http://{HOST}:{PORT}/v1/responses")
1174
+ log(f"模型列表: http://{HOST}:{PORT}/v1/models")
1175
+ log(f"重置缓存: http://{HOST}:{PORT}/v1/reset-run (POST)")
1176
+ log(f"健康检查: http://{HOST}:{PORT}/health")
1177
+ if PROXY_API_KEY:
1178
+ log(f"API 鉴权: 已启用 (Bearer {token_fingerprint(PROXY_API_KEY)})", "success")
1179
+ else:
1180
+ log("API 鉴权: 未启用(所有 /v1 请求均可访问)", "warn")
1181
+ if ADMIN_PASSWORD:
1182
+ log("管理页鉴权: 已启用", "success")
1183
+ else:
1184
+ log("管理页鉴权: 未启用", "warn")
1185
+ web.run_app(app, host=HOST, port=PORT)
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ aiohttp==3.10.11