nanoppa commited on
Commit
22e8efa
·
verified ·
1 Parent(s): f1536a7

Upload 26 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim AS base
2
+
3
+ WORKDIR /app
4
+
5
+ # 安装依赖阶段
6
+ FROM base AS dependencies
7
+
8
+ # 安装运行时需要的系统依赖
9
+ RUN apt-get update && \
10
+ apt-get install -y --no-install-recommends \
11
+ gcc \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # 复制并安装 Python 依赖
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir --upgrade pip && \
17
+ pip install --no-cache-dir -r requirements.txt
18
+
19
+ # 最终运行阶段
20
+ FROM python:3.11-slim AS runtime
21
+
22
+ WORKDIR /app
23
+
24
+ # 复制必要的 Python 依赖
25
+ COPY --from=dependencies /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
26
+ COPY --from=dependencies /usr/local/bin /usr/local/bin
27
+
28
+ # 复制应用代码
29
+ COPY . .
30
+
31
+ # 创建必要的目录和文件
32
+ RUN mkdir -p /app/logs /app/data/temp && \
33
+ echo '{"ssoNormal": {}, "ssoSuper": {}}' > /app/data/token.json
34
+
35
+ EXPOSE 8000
36
+
37
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Chenyme
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app/api/admin/manage.py ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 管理接口模块
3
+
4
+ 提供Token管理功能,包括登录验证、Token增删查等操作。
5
+ """
6
+
7
+ import secrets
8
+ from typing import Dict, Any, List, Optional
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+ from fastapi import APIRouter, HTTPException, Depends, Header
12
+ from fastapi.responses import HTMLResponse
13
+ from pydantic import BaseModel
14
+
15
+ from app.core.config import setting
16
+ from app.core.logger import logger
17
+ from app.services.grok.token import token_manager
18
+ from app.models.grok_models import TokenType
19
+
20
+
21
+ # 创建路由器
22
+ router = APIRouter(tags=["管理"])
23
+
24
+ # 常量定义
25
+ STATIC_DIR = Path(__file__).parents[2] / "template"
26
+ TEMP_DIR = Path(__file__).parents[3] / "data" / "temp"
27
+ SESSION_EXPIRE_HOURS = 24
28
+ BYTES_PER_KB = 1024
29
+ BYTES_PER_MB = 1024 * 1024
30
+
31
+ # 简单的会话存储
32
+ _sessions: Dict[str, datetime] = {}
33
+
34
+
35
+ # === 请求/响应模型 ===
36
+
37
+ class LoginRequest(BaseModel):
38
+ """登录请求"""
39
+ username: str
40
+ password: str
41
+
42
+
43
+ class LoginResponse(BaseModel):
44
+ """登录响应"""
45
+ success: bool
46
+ token: Optional[str] = None
47
+ message: str
48
+
49
+
50
+ class AddTokensRequest(BaseModel):
51
+ """批量添加Token请求"""
52
+ tokens: List[str]
53
+ token_type: str # "sso" 或 "ssoSuper"
54
+
55
+
56
+ class DeleteTokensRequest(BaseModel):
57
+ """批量删除Token请求"""
58
+ tokens: List[str]
59
+ token_type: str # "sso" 或 "ssoSuper"
60
+
61
+
62
+ class TokenInfo(BaseModel):
63
+ """Token信息"""
64
+ token: str
65
+ token_type: str
66
+ created_time: Optional[int] = None
67
+ remaining_queries: int
68
+ heavy_remaining_queries: int
69
+ status: str # "未使用"、"限流中"、"失效"、"正常"
70
+
71
+
72
+ class TokenListResponse(BaseModel):
73
+ """Token列表响应"""
74
+ success: bool
75
+ data: List[TokenInfo]
76
+ total: int
77
+
78
+
79
+ # === 辅助函数 ===
80
+
81
+ def validate_token_type(token_type_str: str) -> TokenType:
82
+ """验证并转换Token类型字符串为枚举"""
83
+ if token_type_str not in ["sso", "ssoSuper"]:
84
+ raise HTTPException(
85
+ status_code=400,
86
+ detail={"error": "无效的Token类型,必须是 'sso' 或 'ssoSuper'", "code": "INVALID_TYPE"}
87
+ )
88
+ return TokenType.NORMAL if token_type_str == "sso" else TokenType.SUPER
89
+
90
+
91
+ def parse_created_time(created_time) -> Optional[int]:
92
+ """解析创建时间,统一处理不同格式"""
93
+ if isinstance(created_time, str):
94
+ return int(created_time) if created_time else None
95
+ elif isinstance(created_time, int):
96
+ return created_time
97
+ return None
98
+
99
+
100
+ def calculate_token_stats(tokens: Dict[str, Any], token_type: str) -> Dict[str, int]:
101
+ """计算Token统计信息"""
102
+ total = len(tokens)
103
+ expired = sum(1 for t in tokens.values() if t.get("status") == "expired")
104
+
105
+ if token_type == "normal":
106
+ unused = sum(1 for t in tokens.values()
107
+ if t.get("status") != "expired" and t.get("remainingQueries", -1) == -1)
108
+ limited = sum(1 for t in tokens.values()
109
+ if t.get("status") != "expired" and t.get("remainingQueries", -1) == 0)
110
+ active = sum(1 for t in tokens.values()
111
+ if t.get("status") != "expired" and t.get("remainingQueries", -1) > 0)
112
+ else: # super token
113
+ unused = sum(1 for t in tokens.values()
114
+ if t.get("status") != "expired" and
115
+ t.get("remainingQueries", -1) == -1 and t.get("heavyremainingQueries", -1) == -1)
116
+ limited = sum(1 for t in tokens.values()
117
+ if t.get("status") != "expired" and
118
+ (t.get("remainingQueries", -1) == 0 or t.get("heavyremainingQueries", -1) == 0))
119
+ active = sum(1 for t in tokens.values()
120
+ if t.get("status") != "expired" and
121
+ (t.get("remainingQueries", -1) > 0 or t.get("heavyremainingQueries", -1) > 0))
122
+
123
+ return {
124
+ "total": total,
125
+ "unused": unused,
126
+ "limited": limited,
127
+ "expired": expired,
128
+ "active": active
129
+ }
130
+
131
+
132
+ def verify_admin_session(authorization: Optional[str] = Header(None)) -> bool:
133
+ """验证管理员会话"""
134
+ if not authorization or not authorization.startswith("Bearer "):
135
+ raise HTTPException(
136
+ status_code=401,
137
+ detail={"error": "未授权访问", "code": "UNAUTHORIZED"}
138
+ )
139
+
140
+ token = authorization[7:] # 移除 "Bearer " 前缀
141
+
142
+ # 检查token是否存在且未过期
143
+ if token not in _sessions:
144
+ raise HTTPException(
145
+ status_code=401,
146
+ detail={"error": "会话已过期或无效", "code": "SESSION_INVALID"}
147
+ )
148
+
149
+ # 检查会话是否过期(24小时)
150
+ if datetime.now() > _sessions[token]:
151
+ del _sessions[token]
152
+ raise HTTPException(
153
+ status_code=401,
154
+ detail={"error": "会话已过期", "code": "SESSION_EXPIRED"}
155
+ )
156
+
157
+ return True
158
+
159
+
160
+ def get_token_status(token_data: Dict[str, Any], token_type: str) -> str:
161
+ """获取Token状态"""
162
+ # 首先检查是否失效(来自 token.json 的 status 字段)
163
+ if token_data.get("status") == "expired":
164
+ return "失效"
165
+
166
+ # 获取剩余次数
167
+ remaining_queries = token_data.get("remainingQueries", -1)
168
+ heavy_remaining = token_data.get("heavyremainingQueries", -1)
169
+
170
+ # 根据token类型选择正确的字段
171
+ if token_type == "ssoSuper":
172
+ # Super token 可能使用 heavy 模型
173
+ relevant_remaining = max(remaining_queries, heavy_remaining)
174
+ else:
175
+ # 普通token主要看 remaining_queries
176
+ relevant_remaining = remaining_queries
177
+
178
+ if relevant_remaining == -1:
179
+ return "未使用"
180
+ elif relevant_remaining == 0:
181
+ return "限流中"
182
+ else:
183
+ return "正常"
184
+
185
+
186
+ # === 页面路由 ===
187
+
188
+ @router.get("/login", response_class=HTMLResponse)
189
+ async def login_page():
190
+ """登录页面"""
191
+ login_html = STATIC_DIR / "login.html"
192
+ if login_html.exists():
193
+ return login_html.read_text(encoding="utf-8")
194
+ raise HTTPException(status_code=404, detail="登录页面不存在")
195
+
196
+
197
+ @router.get("/manage", response_class=HTMLResponse)
198
+ async def manage_page():
199
+ """管理页面"""
200
+ admin_html = STATIC_DIR / "admin.html"
201
+ if admin_html.exists():
202
+ return admin_html.read_text(encoding="utf-8")
203
+ raise HTTPException(status_code=404, detail="管理页面不存在")
204
+
205
+
206
+ # === API端点 ===
207
+
208
+ @router.post("/api/login", response_model=LoginResponse)
209
+ async def admin_login(request: LoginRequest) -> LoginResponse:
210
+ """
211
+ 管理员登录
212
+
213
+ 验证用户名和密码,成功后返回会话token。
214
+ """
215
+ try:
216
+ logger.debug(f"[Admin] 管理员登录尝试 - 用户名: {request.username}")
217
+
218
+ # 验证用户名和密码
219
+ expected_username = setting.global_config.get("admin_username", "")
220
+ expected_password = setting.global_config.get("admin_password", "")
221
+
222
+ if request.username != expected_username or request.password != expected_password:
223
+ logger.warning(f"[Admin] 登录失败: 用户名或密码错误 - 用户名: {request.username}")
224
+ return LoginResponse(
225
+ success=False,
226
+ message="用户名或密码错误"
227
+ )
228
+
229
+ # 生成会话token
230
+ session_token = secrets.token_urlsafe(32)
231
+
232
+ # 设置会话过期时间
233
+ expire_time = datetime.now() + timedelta(hours=SESSION_EXPIRE_HOURS)
234
+ _sessions[session_token] = expire_time
235
+
236
+ logger.debug(f"[Admin] 管理员登录成功 - 用户名: {request.username}")
237
+
238
+ return LoginResponse(
239
+ success=True,
240
+ token=session_token,
241
+ message="登录成功"
242
+ )
243
+
244
+ except Exception as e:
245
+ logger.error(f"[Admin] 登录处理异常 - 用户名: {request.username}, 错误: {str(e)}")
246
+ raise HTTPException(
247
+ status_code=500,
248
+ detail={"error": f"登录失败: {str(e)}", "code": "LOGIN_ERROR"}
249
+ )
250
+
251
+
252
+ @router.post("/api/logout")
253
+ async def admin_logout(authenticated: bool = Depends(verify_admin_session),
254
+ authorization: Optional[str] = Header(None)) -> Dict[str, Any]:
255
+ """
256
+ 管理员登出
257
+
258
+ 清除会话token。
259
+ """
260
+ try:
261
+ if authorization and authorization.startswith("Bearer "):
262
+ token = authorization[7:]
263
+ if token in _sessions:
264
+ del _sessions[token]
265
+ logger.debug("[Admin] 管理员登出成功")
266
+ return {"success": True, "message": "登出成功"}
267
+
268
+ logger.warning("[Admin] 登出失败: 无效或缺失的会话Token")
269
+ return {"success": False, "message": "无效的会话"}
270
+
271
+ except Exception as e:
272
+ logger.error(f"[Admin] 登出处理异常 - 错误: {str(e)}")
273
+ raise HTTPException(
274
+ status_code=500,
275
+ detail={"error": f"登出失败: {str(e)}", "code": "LOGOUT_ERROR"}
276
+ )
277
+
278
+
279
+ @router.get("/api/tokens", response_model=TokenListResponse)
280
+ async def list_tokens(authenticated: bool = Depends(verify_admin_session)) -> TokenListResponse:
281
+ """
282
+ 获取所有Token列表
283
+
284
+ 返回系统中所有Token及其状态信息。
285
+ """
286
+ try:
287
+ logger.debug("[Admin] 开始获取Token列表")
288
+
289
+ all_tokens_data = token_manager.get_tokens()
290
+ token_list: List[TokenInfo] = []
291
+
292
+ # 处理普通Token
293
+ normal_tokens = all_tokens_data.get(TokenType.NORMAL.value, {})
294
+ for token, data in normal_tokens.items():
295
+ token_list.append(TokenInfo(
296
+ token=token,
297
+ token_type="sso",
298
+ created_time=parse_created_time(data.get("createdTime")),
299
+ remaining_queries=data.get("remainingQueries", -1),
300
+ heavy_remaining_queries=data.get("heavyremainingQueries", -1),
301
+ status=get_token_status(data, "sso")
302
+ ))
303
+
304
+ # 处理Super Token
305
+ super_tokens = all_tokens_data.get(TokenType.SUPER.value, {})
306
+ for token, data in super_tokens.items():
307
+ token_list.append(TokenInfo(
308
+ token=token,
309
+ token_type="ssoSuper",
310
+ created_time=parse_created_time(data.get("createdTime")),
311
+ remaining_queries=data.get("remainingQueries", -1),
312
+ heavy_remaining_queries=data.get("heavyremainingQueries", -1),
313
+ status=get_token_status(data, "ssoSuper")
314
+ ))
315
+
316
+ normal_count = len(normal_tokens)
317
+ super_count = len(super_tokens)
318
+ total_count = len(token_list)
319
+
320
+ logger.debug(f"[Admin] Token列表获取成功 - 普通Token: {normal_count}, Super Token: {super_count}, 总计: {total_count}")
321
+
322
+ return TokenListResponse(
323
+ success=True,
324
+ data=token_list,
325
+ total=total_count
326
+ )
327
+
328
+ except Exception as e:
329
+ logger.error(f"[Admin] 获取Token列表异常 - 错误: {str(e)}")
330
+ raise HTTPException(
331
+ status_code=500,
332
+ detail={"error": f"获取Token列表失败: {str(e)}", "code": "LIST_ERROR"}
333
+ )
334
+
335
+
336
+ @router.post("/api/tokens/add")
337
+ async def add_tokens(request: AddTokensRequest,
338
+ authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
339
+ """
340
+ 批量添加Token
341
+
342
+ 支持添加普通Token(sso)和Super Token(ssoSuper)。
343
+ """
344
+ try:
345
+ logger.debug(f"[Admin] 批量添加Token - 类型: {request.token_type}, 数量: {len(request.tokens)}")
346
+
347
+ # 验证并转换token类型
348
+ token_type = validate_token_type(request.token_type)
349
+
350
+ # 添加Token
351
+ await token_manager.add_token(request.tokens, token_type)
352
+
353
+ logger.debug(f"[Admin] Token添加成功 - 类型: {request.token_type}, 数量: {len(request.tokens)}")
354
+
355
+ return {
356
+ "success": True,
357
+ "message": f"成功添加 {len(request.tokens)} 个Token",
358
+ "count": len(request.tokens)
359
+ }
360
+
361
+ except HTTPException:
362
+ raise
363
+ except Exception as e:
364
+ logger.error(f"[Admin] Token添加异常 - 类型: {request.token_type}, 数量: {len(request.tokens)}, 错误: {str(e)}")
365
+ raise HTTPException(
366
+ status_code=500,
367
+ detail={"error": f"添加Token失败: {str(e)}", "code": "ADD_ERROR"}
368
+ )
369
+
370
+
371
+ @router.post("/api/tokens/delete")
372
+ async def delete_tokens(request: DeleteTokensRequest,
373
+ authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
374
+ """
375
+ 批量删除Token
376
+
377
+ 支持删除普通Token(sso)和Super Token(ssoSuper)。
378
+ """
379
+ try:
380
+ logger.debug(f"[Admin] 批量删除Token - 类型: {request.token_type}, 数量: {len(request.tokens)}")
381
+
382
+ # 验证并转换token类型
383
+ token_type = validate_token_type(request.token_type)
384
+
385
+ # 删除Token
386
+ await token_manager.delete_token(request.tokens, token_type)
387
+
388
+ logger.debug(f"[Admin] Token删除成功 - 类型: {request.token_type}, 数量: {len(request.tokens)}")
389
+
390
+ return {
391
+ "success": True,
392
+ "message": f"成功删除 {len(request.tokens)} 个Token",
393
+ "count": len(request.tokens)
394
+ }
395
+
396
+ except HTTPException:
397
+ raise
398
+ except Exception as e:
399
+ logger.error(f"[Admin] Token删除异常 - 类型: {request.token_type}, 数量: {len(request.tokens)}, 错误: {str(e)}")
400
+ raise HTTPException(
401
+ status_code=500,
402
+ detail={"error": f"删除Token失败: {str(e)}", "code": "DELETE_ERROR"}
403
+ )
404
+
405
+
406
+ @router.get("/api/settings")
407
+ async def get_settings(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
408
+ """获取全局配置"""
409
+ try:
410
+ logger.debug("[Admin] 获取全局配置")
411
+ return {
412
+ "success": True,
413
+ "data": {
414
+ "global": setting.global_config,
415
+ "grok": setting.grok_config
416
+ }
417
+ }
418
+ except Exception as e:
419
+ logger.error(f"[Admin] 获取配置失败: {str(e)}")
420
+ raise HTTPException(status_code=500, detail={"error": f"获取配置失败: {str(e)}", "code": "GET_SETTINGS_ERROR"})
421
+
422
+
423
+ class UpdateSettingsRequest(BaseModel):
424
+ """更新配置请求"""
425
+ global_config: Optional[Dict[str, Any]] = None
426
+ grok_config: Optional[Dict[str, Any]] = None
427
+
428
+
429
+ @router.post("/api/settings")
430
+ async def update_settings(request: UpdateSettingsRequest, authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
431
+ """更新全局配置"""
432
+ try:
433
+ import toml
434
+ import aiofiles
435
+ logger.debug("[Admin] 更新全局配置")
436
+
437
+ # 异步读取现有配置
438
+ async with aiofiles.open(setting.config_path, "r", encoding="utf-8") as f:
439
+ content = await f.read()
440
+ config = toml.loads(content)
441
+
442
+ # 更新配置
443
+ if request.global_config:
444
+ config["global"].update(request.global_config)
445
+ if request.grok_config:
446
+ config["grok"].update(request.grok_config)
447
+
448
+ # 异步写回配置文件
449
+ async with aiofiles.open(setting.config_path, "w", encoding="utf-8") as f:
450
+ await f.write(toml.dumps(config))
451
+
452
+ # 重新加载配置
453
+ setting.global_config = setting.load("global")
454
+ setting.grok_config = setting.load("grok")
455
+
456
+ logger.debug("[Admin] 配置更新成功")
457
+ return {"success": True, "message": "配置更新成功"}
458
+ except Exception as e:
459
+ logger.error(f"[Admin] 更新配置失败: {str(e)}")
460
+ raise HTTPException(status_code=500, detail={"error": f"更新配置失败: {str(e)}", "code": "UPDATE_SETTINGS_ERROR"})
461
+
462
+
463
+ def _calculate_dir_size(directory: Path) -> int:
464
+ """计算目录中所有文件的大小(字节)"""
465
+ total_size = 0
466
+ for file_path in directory.iterdir():
467
+ if file_path.is_file():
468
+ try:
469
+ total_size += file_path.stat().st_size
470
+ except Exception as e:
471
+ logger.warning(f"[Admin] 无法获取文件大小: {file_path.name}, 错误: {str(e)}")
472
+ return total_size
473
+
474
+
475
+ def _format_size(size_bytes: int) -> str:
476
+ """格式化字节大小为可读字符串"""
477
+ size_mb = size_bytes / BYTES_PER_MB
478
+ if size_mb < 1:
479
+ size_kb = size_bytes / BYTES_PER_KB
480
+ return f"{size_kb:.1f} KB"
481
+ return f"{size_mb:.1f} MB"
482
+
483
+
484
+ @router.get("/api/cache/size")
485
+ async def get_cache_size(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
486
+ """获取缓存大小"""
487
+ try:
488
+ logger.debug("[Admin] 开始获取缓存大小")
489
+
490
+ if not TEMP_DIR.exists():
491
+ logger.warning(f"[Admin] 缓存目录不存在: {TEMP_DIR}")
492
+ return {"success": True, "data": {"size": "0 MB"}}
493
+
494
+ # 计算目录大小
495
+ total_size = _calculate_dir_size(TEMP_DIR)
496
+ size_str = _format_size(total_size)
497
+
498
+ logger.debug(f"[Admin] 缓存大小获取完成 - 大小: {size_str}")
499
+ return {"success": True, "data": {"size": size_str}}
500
+
501
+ except Exception as e:
502
+ logger.error(f"[Admin] 获取缓存大小异常 - 错误: {str(e)}")
503
+ raise HTTPException(
504
+ status_code=500,
505
+ detail={"error": f"获取缓存大小失败: {str(e)}", "code": "CACHE_SIZE_ERROR"}
506
+ )
507
+
508
+
509
+ @router.post("/api/cache/clear")
510
+ async def clear_cache(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
511
+ """清理缓存 - 删除所有临时文件"""
512
+ try:
513
+ logger.debug("[Admin] 开始清理缓存")
514
+
515
+ if not TEMP_DIR.exists():
516
+ logger.warning(f"[Admin] 缓存目录不存在: {TEMP_DIR}")
517
+ return {
518
+ "success": True,
519
+ "message": "缓存目录不存在,无需清理",
520
+ "data": {"deleted_count": 0}
521
+ }
522
+
523
+ # 删除所有文件
524
+ deleted_count = 0
525
+ for file_path in TEMP_DIR.iterdir():
526
+ if file_path.is_file():
527
+ try:
528
+ file_path.unlink()
529
+ deleted_count += 1
530
+ logger.debug(f"[Admin] 删除缓存文件: {file_path.name}")
531
+ except Exception as e:
532
+ logger.error(f"[Admin] 删除缓存文件失败: {file_path.name}, 错误: {str(e)}")
533
+
534
+ logger.debug(f"[Admin] 缓存清理完成 - 删除文件数量: {deleted_count}")
535
+ return {
536
+ "success": True,
537
+ "message": f"成功清理缓存,删除 {deleted_count} 个文件",
538
+ "data": {"deleted_count": deleted_count}
539
+ }
540
+
541
+ except Exception as e:
542
+ logger.error(f"[Admin] 清理缓存异常 - 错误: {str(e)}")
543
+ raise HTTPException(
544
+ status_code=500,
545
+ detail={"error": f"清理缓存失败: {str(e)}", "code": "CACHE_CLEAR_ERROR"}
546
+ )
547
+
548
+
549
+ @router.get("/api/stats")
550
+ async def get_stats(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
551
+ """
552
+ 获取统计信息
553
+
554
+ 返回Token的统计数据。
555
+ """
556
+ try:
557
+ logger.debug("[Admin] 开始获取统计信息")
558
+
559
+ all_tokens_data = token_manager.get_tokens()
560
+
561
+ # 统计普通Token
562
+ normal_tokens = all_tokens_data.get(TokenType.NORMAL.value, {})
563
+ normal_stats = calculate_token_stats(normal_tokens, "normal")
564
+
565
+ # 统计Super Token
566
+ super_tokens = all_tokens_data.get(TokenType.SUPER.value, {})
567
+ super_stats = calculate_token_stats(super_tokens, "super")
568
+
569
+ total_count = normal_stats["total"] + super_stats["total"]
570
+
571
+ stats = {
572
+ "success": True,
573
+ "data": {
574
+ "normal": normal_stats,
575
+ "super": super_stats,
576
+ "total": total_count
577
+ }
578
+ }
579
+
580
+ logger.debug(f"[Admin] 统计信息获取成功 - 普通Token: {normal_stats['total']}, Super Token: {super_stats['total']}, 总计: {total_count}")
581
+ return stats
582
+
583
+ except Exception as e:
584
+ logger.error(f"[Admin] 获取统计信息异常 - 错误: {str(e)}")
585
+ raise HTTPException(
586
+ status_code=500,
587
+ detail={"error": f"获取统计信息失败: {str(e)}", "code": "STATS_ERROR"}
588
+ )
app/api/v1/chat.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 聊天API路由模块
4
+
5
+ 提供OpenAI兼容的聊天API接口,支持与Grok模型的交互。
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from typing import Optional
10
+ from fastapi.responses import StreamingResponse
11
+
12
+ from app.core.auth import auth_manager
13
+ from app.core.exception import GrokApiException
14
+ from app.core.logger import logger
15
+ from app.services.grok.client import GrokClient
16
+ from app.models.openai_schema import OpenAIChatRequest
17
+
18
+ # 聊天路由
19
+ router = APIRouter(prefix="/chat", tags=["聊天"])
20
+
21
+
22
+ @router.post("/completions", response_model=None)
23
+ async def chat_completions(
24
+ request: OpenAIChatRequest,
25
+ authenticated: Optional[str] = Depends(auth_manager.verify)
26
+ ):
27
+ """
28
+ 创建聊天补全
29
+
30
+ 兼容OpenAI聊天API的端点,支持流式和非流式响应。
31
+
32
+ Args:
33
+ request: OpenAI格式的聊天请求
34
+ authenticated: 认证状态(由依赖注入)
35
+
36
+ Returns:
37
+ OpenAIChatCompletionResponse: 非流式响应
38
+ StreamingResponse: 流式响应
39
+
40
+ Raises:
41
+ HTTPException: 当请求处理失败时
42
+ """
43
+ try:
44
+ logger.info(f"[Chat] 聊天请求 - 模型: {request.model}")
45
+
46
+ # 调用Grok客户端处理请求
47
+ result = await GrokClient.openai_to_grok(request.model_dump())
48
+
49
+ # 如果是流式响应,GrokClient已经返回了Iterator,直接包装为StreamingResponse
50
+ if request.stream:
51
+ return StreamingResponse(
52
+ content=result,
53
+ media_type="text/event-stream",
54
+ headers={
55
+ "Cache-Control": "no-cache",
56
+ "Connection": "keep-alive",
57
+ "X-Accel-Buffering": "no"
58
+ }
59
+ )
60
+
61
+ # 非流式响应直接返回
62
+ return result
63
+
64
+ except GrokApiException as e:
65
+ logger.error(f"[Chat] Grok API错误: {str(e)}", extra={"details": e.details})
66
+ raise HTTPException(
67
+ status_code=500,
68
+ detail={
69
+ "error": {
70
+ "message": str(e),
71
+ "type": e.error_code or "grok_api_error",
72
+ "code": e.error_code or "unknown"
73
+ }
74
+ }
75
+ )
76
+ except Exception as e:
77
+ logger.error(f"[Chat] 聊天请求处理失败: {str(e)}", exc_info=True)
78
+ raise HTTPException(
79
+ status_code=500,
80
+ detail={
81
+ "error": {
82
+ "message": "服务器内部错误",
83
+ "type": "internal_error",
84
+ "code": "internal_server_error"
85
+ }
86
+ }
87
+ )
app/api/v1/images.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """图片服务API路由"""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from fastapi.responses import FileResponse
5
+
6
+ from app.core.logger import logger
7
+ from app.services.grok.image_cache import image_cache_service
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/images/{img_path:path}")
14
+ async def get_image(img_path: str):
15
+ """获取缓存的图片
16
+
17
+ Args:
18
+ img_path: 图片路径,格式如 users-xxx-generated-xxx-image.jpg
19
+
20
+ Returns:
21
+ 图片文件响应
22
+ """
23
+ try:
24
+ # 将路径转换回原始格式(短横线转斜杠)
25
+ original_path = "/" + img_path.replace('-', '/')
26
+
27
+ # 检查缓存是否存在
28
+ cache_path = image_cache_service.get_cached_image(original_path)
29
+
30
+ if cache_path and cache_path.exists():
31
+ logger.debug(f"[ImageAPI] 返回缓存图片: {cache_path}")
32
+ return FileResponse(
33
+ path=str(cache_path),
34
+ media_type="image/jpeg",
35
+ headers={
36
+ "Cache-Control": "public, max-age=86400",
37
+ "Access-Control-Allow-Origin": "*"
38
+ }
39
+ )
40
+
41
+ # 图片不存在
42
+ logger.warning(f"[ImageAPI] 图片未找到: {original_path}")
43
+ raise HTTPException(status_code=404, detail="Image not found")
44
+
45
+ except HTTPException:
46
+ raise
47
+ except Exception as e:
48
+ logger.error(f"[ImageAPI] 获取图片失败: {e}")
49
+ raise HTTPException(status_code=500, detail=str(e))
app/api/v1/models.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 模型接口模块
3
+
4
+ 提供 OpenAI 兼容的 /v1/models 端点,返回系统支持的所有模型列表。
5
+ """
6
+
7
+ import time
8
+ from typing import Dict, Any, List, Optional
9
+ from fastapi import APIRouter, HTTPException, Depends
10
+
11
+ from app.models.grok_models import Models
12
+ from app.core.auth import auth_manager
13
+ from app.core.logger import logger
14
+
15
+ # 配置日志
16
+
17
+ # 创建路由器
18
+ router = APIRouter(tags=["模型"])
19
+
20
+
21
+ @router.get("/models")
22
+ async def list_models(authenticated: Optional[str] = Depends(auth_manager.verify)) -> Dict[str, Any]:
23
+ """
24
+ 获取可用模型列表
25
+
26
+ 返回 OpenAI 兼容的模型列表格式,包含系统支持的所有 Grok 模型的详细信息。
27
+
28
+ Args:
29
+ authenticated: 认证状态(由依赖注入)
30
+
31
+ Returns:
32
+ Dict[str, Any]: 包含模型列表的响应数据
33
+ """
34
+ try:
35
+ logger.debug("[Models] 请求获取模型列表")
36
+
37
+ # 获取当前时间戳
38
+ current_timestamp = int(time.time())
39
+
40
+ # 构建模型数据列表
41
+ model_data: List[Dict[str, Any]] = []
42
+
43
+ for model in Models:
44
+ model_id = model.value
45
+ config = Models.get_model_info(model_id)
46
+
47
+ # 基础信息
48
+ model_info = {
49
+ "id": model_id,
50
+ "object": "model",
51
+ "created": current_timestamp,
52
+ "owned_by": "x-ai",
53
+ "display_name": config.get("display_name", model_id),
54
+ "description": config.get("description", ""),
55
+ "raw_model_path": config.get("raw_model_path", f"xai/{model_id}"),
56
+ "default_temperature": config.get("default_temperature", 1.0),
57
+ "default_max_output_tokens": config.get("default_max_output_tokens", 8192),
58
+ "supported_max_output_tokens": config.get("supported_max_output_tokens", 131072),
59
+ "default_top_p": config.get("default_top_p", 0.95)
60
+ }
61
+
62
+ model_data.append(model_info)
63
+
64
+ # 构建响应
65
+ response = {
66
+ "object": "list",
67
+ "data": model_data
68
+ }
69
+
70
+ logger.debug(f"[Models] 成功返回 {len(model_data)} 个模型")
71
+ return response
72
+
73
+ except Exception as e:
74
+ logger.error(f"[Models] 获取模型列表时发生错误: {str(e)}")
75
+ raise HTTPException(
76
+ status_code=500,
77
+ detail={
78
+ "error": {
79
+ "message": f"Failed to retrieve models: {str(e)}",
80
+ "type": "internal_error",
81
+ "code": "model_list_error"
82
+ }
83
+ }
84
+ )
85
+
86
+
87
+ @router.get("/models/{model_id}")
88
+ async def get_model(model_id: str, authenticated: Optional[str] = Depends(auth_manager.verify)) -> Dict[str, Any]:
89
+ """
90
+ 获取特定模型信息
91
+
92
+ Args:
93
+ model_id (str): 模型ID
94
+ authenticated: 认证状态(由依赖注入)
95
+
96
+ Returns:
97
+ Dict[str, Any]: 模型详细信息
98
+ """
99
+ try:
100
+ logger.debug(f"[Models] 请求获取模型信息: {model_id}")
101
+
102
+ # 验证模型是否存在
103
+ if not Models.is_valid_model(model_id):
104
+ logger.warning(f"[Models] 请求的模型不存在: {model_id}")
105
+ raise HTTPException(
106
+ status_code=404,
107
+ detail={
108
+ "error": {
109
+ "message": f"Model '{model_id}' not found",
110
+ "type": "invalid_request_error",
111
+ "code": "model_not_found"
112
+ }
113
+ }
114
+ )
115
+
116
+ # 获取当前时间戳
117
+ current_timestamp = int(time.time())
118
+
119
+ # 获取模型配置
120
+ config = Models.get_model_info(model_id)
121
+
122
+ # 构建模型信息
123
+ model_info = {
124
+ "id": model_id,
125
+ "object": "model",
126
+ "created": current_timestamp,
127
+ "owned_by": "x-ai",
128
+ "display_name": config.get("display_name", model_id),
129
+ "description": config.get("description", ""),
130
+ "raw_model_path": config.get("raw_model_path", f"xai/{model_id}"),
131
+ "default_temperature": config.get("default_temperature", 1.0),
132
+ "default_max_output_tokens": config.get("default_max_output_tokens", 8192),
133
+ "supported_max_output_tokens": config.get("supported_max_output_tokens", 131072),
134
+ "default_top_p": config.get("default_top_p", 0.95)
135
+ }
136
+
137
+ logger.debug(f"[Models] 成功返回模型信息: {model_id}")
138
+ return model_info
139
+
140
+ except HTTPException:
141
+ raise
142
+ except Exception as e:
143
+ logger.error(f"[Models] 获取模型信息时发生错误: {str(e)}")
144
+ raise HTTPException(
145
+ status_code=500,
146
+ detail={
147
+ "error": {
148
+ "message": f"Failed to retrieve model: {str(e)}",
149
+ "type": "internal_error",
150
+ "code": "model_retrieve_error"
151
+ }
152
+ }
153
+ )
app/core/auth.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """认证模块"""
2
+
3
+ from typing import Optional
4
+ from fastapi import Depends, HTTPException
5
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
6
+ from app.core.config import setting
7
+ from app.core.logger import logger
8
+
9
+
10
+ security = HTTPBearer(auto_error=False)
11
+
12
+
13
+ class AuthManager:
14
+ """认证管理器"""
15
+
16
+ @staticmethod
17
+ def verify(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[str]:
18
+ """验证认证令牌"""
19
+ api_key = setting.grok_config.get("api_key")
20
+
21
+ if not api_key:
22
+ logger.debug("[Auth] 未设置API_KEY,跳过验证。")
23
+ return credentials.credentials if credentials else None
24
+
25
+ if not credentials:
26
+ raise HTTPException(
27
+ status_code=401,
28
+ detail={
29
+ "error": {
30
+ "message": "缺少认证令牌",
31
+ "type": "authentication_error",
32
+ "code": "missing_token"
33
+ }
34
+ }
35
+ )
36
+
37
+ if credentials.credentials != api_key:
38
+ raise HTTPException(
39
+ status_code=401,
40
+ detail={
41
+ "error": {
42
+ "message": f"令牌无效,长度: {len(credentials.credentials)}",
43
+ "type": "authentication_error",
44
+ "code": "invalid_token"
45
+ }
46
+ }
47
+ )
48
+
49
+ logger.debug("[Auth] 令牌认证成功。")
50
+ return credentials.credentials
51
+
52
+
53
+ auth_manager = AuthManager()
app/core/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """配置管理器"""
2
+
3
+ import toml
4
+ from pathlib import Path
5
+ from typing import Dict, Any
6
+
7
+
8
+ class ConfigManager:
9
+ """配置管理器"""
10
+
11
+ def __init__(self) -> None:
12
+ """初始化"""
13
+
14
+ # 加载环境变量
15
+ self.config_path: Path = Path(__file__).parents[2] / "data" / "setting.toml"
16
+ self.global_config: Dict[str, Any] = self.load("global")
17
+ self.grok_config: Dict[str, Any] = self.load("grok")
18
+
19
+ def load(self, section: str) -> Dict[str, Any]:
20
+ """配置加载器"""
21
+ try:
22
+ with open(self.config_path, "r", encoding="utf-8") as f:
23
+ return toml.load(f)[section]
24
+ except Exception as e:
25
+ raise Exception(f"[Setting] 配置加载失败: {e}")
26
+
27
+ # 全局设置
28
+ setting = ConfigManager()
29
+
30
+ if __name__ == "__main__":
31
+ print(setting.global_config)
32
+ print(setting.grok_config)
app/core/exception.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """异常处理器"""
2
+
3
+ from fastapi import Request, status
4
+ from fastapi.responses import JSONResponse
5
+ from fastapi.exceptions import RequestValidationError
6
+ from starlette.exceptions import HTTPException as StarletteHTTPException
7
+
8
+
9
+ class GrokApiException(Exception):
10
+ """Grok API 业务异常"""
11
+
12
+ def __init__(self, message: str, error_code: str = None, details: dict = None):
13
+ self.message = message
14
+ self.error_code = error_code
15
+ self.details = details or {}
16
+ super().__init__(self.message)
17
+
18
+
19
+ def build_error_response(message: str, error_type: str, code: str = None, param: str = None) -> dict:
20
+ """构建OpenAI兼容的错误响应"""
21
+ error = {
22
+ "message": message,
23
+ "type": error_type,
24
+ }
25
+ if code:
26
+ error["code"] = code
27
+ if param:
28
+ error["param"] = param
29
+
30
+ return {"error": error}
31
+
32
+
33
+ async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
34
+ """处理HTTP异常"""
35
+ error_map = {
36
+ 400: ("invalid_request_error", "请求格式错误或缺少必填参数。"),
37
+ 401: ("invalid_request_error", "令牌认证失败。"),
38
+ 403: ("permission_error", "没有权限访问此资源。"),
39
+ 404: ("invalid_request_error", "请求的资源不存在。"),
40
+ 429: ("rate_limit_error", "请求频率超出限制,请稍后再试。"),
41
+ 500: ("api_error", "内部服务器错误。"),
42
+ 503: ("api_error", "服务暂时不可用。"),
43
+ }
44
+
45
+ error_type, default_message = error_map.get(exc.status_code, ("api_error", str(exc.detail)))
46
+ message = str(exc.detail) if exc.detail else default_message
47
+
48
+ return JSONResponse(
49
+ status_code=exc.status_code,
50
+ content=build_error_response(message, error_type)
51
+ )
52
+
53
+
54
+ async def validation_exception_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
55
+ """处理验证错误"""
56
+ errors = exc.errors()
57
+ param = errors[0]["loc"][-1] if errors and errors[0].get("loc") else None
58
+ message = errors[0]["msg"] if errors and errors[0].get("msg") else "请求参数错误。"
59
+
60
+ return JSONResponse(
61
+ status_code=status.HTTP_400_BAD_REQUEST,
62
+ content=build_error_response(message, "invalid_request_error", param=param)
63
+ )
64
+
65
+
66
+ async def grok_api_exception_handler(_: Request, exc: GrokApiException) -> JSONResponse:
67
+ """处理Grok API业务异常"""
68
+ # 根据错误码映射HTTP状态码
69
+ status_code_map = {
70
+ "NO_AUTH_TOKEN": status.HTTP_401_UNAUTHORIZED,
71
+ "INVALID_TOKEN": status.HTTP_401_UNAUTHORIZED,
72
+ "HTTP_ERROR": status.HTTP_502_BAD_GATEWAY,
73
+ "NETWORK_ERROR": status.HTTP_503_SERVICE_UNAVAILABLE,
74
+ "JSON_ERROR": status.HTTP_502_BAD_GATEWAY,
75
+ "API_ERROR": status.HTTP_502_BAD_GATEWAY,
76
+ "STREAM_ERROR": status.HTTP_502_BAD_GATEWAY,
77
+ "NO_RESPONSE": status.HTTP_502_BAD_GATEWAY,
78
+ "TOKEN_SAVE_ERROR": status.HTTP_500_INTERNAL_SERVER_ERROR,
79
+ "NO_AVAILABLE_TOKEN": status.HTTP_503_SERVICE_UNAVAILABLE,
80
+ }
81
+
82
+ http_status = status_code_map.get(exc.error_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
83
+ error_type_map = {
84
+ "NO_AUTH_TOKEN": "authentication_error",
85
+ "INVALID_TOKEN": "authentication_error",
86
+ "HTTP_ERROR": "api_error",
87
+ "NETWORK_ERROR": "api_error",
88
+ "JSON_ERROR": "api_error",
89
+ "API_ERROR": "api_error",
90
+ "STREAM_ERROR": "api_error",
91
+ "NO_RESPONSE": "api_error",
92
+ "TOKEN_SAVE_ERROR": "api_error",
93
+ "NO_AVAILABLE_TOKEN": "api_error",
94
+ }
95
+
96
+ error_type = error_type_map.get(exc.error_code, "api_error")
97
+
98
+ return JSONResponse(
99
+ status_code=http_status,
100
+ content=build_error_response(exc.message, error_type, exc.error_code)
101
+ )
102
+
103
+
104
+ async def global_exception_handler(_: Request) -> JSONResponse:
105
+ """处理未捕获异常"""
106
+ return JSONResponse(
107
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
108
+ content=build_error_response(
109
+ "服务器遇到意外错误,请重试。",
110
+ "api_error"
111
+ )
112
+ )
113
+
114
+
115
+ def register_exception_handlers(app) -> None:
116
+ """注册OpenAI兼容的异常处理器"""
117
+ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
118
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
119
+ app.add_exception_handler(GrokApiException, grok_api_exception_handler)
120
+ app.add_exception_handler(Exception, global_exception_handler)
app/core/logger.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """全局日志模块"""
2
+
3
+ import sys
4
+ import logging
5
+ from pathlib import Path
6
+ from logging.handlers import RotatingFileHandler
7
+ from app.core.config import setting
8
+
9
+
10
+ class LoggerManager:
11
+ """日志管理器"""
12
+
13
+ _initialized = False
14
+
15
+ def __init__(self):
16
+ """初始化日志"""
17
+ if LoggerManager._initialized:
18
+ return
19
+
20
+ # 日志配置
21
+ log_dir = Path(__file__).parents[2] / "logs"
22
+ log_dir.mkdir(exist_ok=True)
23
+ log_level = setting.global_config.get("log_level", "INFO").upper()
24
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
25
+ log_file = log_dir / "app.log"
26
+
27
+ # 配置根日志器
28
+ self.logger = logging.getLogger()
29
+ self.logger.setLevel(log_level)
30
+
31
+ # 避免重复添加处理器
32
+ if self.logger.handlers:
33
+ return
34
+
35
+ # 控制台处理器
36
+ console_handler = logging.StreamHandler(sys.stdout)
37
+ console_handler.setLevel(log_level)
38
+ console_handler.setFormatter(logging.Formatter(log_format))
39
+
40
+ # 文件处理器
41
+ file_handler = RotatingFileHandler(
42
+ log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
43
+ )
44
+ file_handler.setLevel(log_level)
45
+ file_handler.setFormatter(logging.Formatter(log_format))
46
+
47
+ self.logger.addHandler(console_handler)
48
+ self.logger.addHandler(file_handler)
49
+
50
+ LoggerManager._initialized = True
51
+
52
+ def debug(self, msg: str) -> None:
53
+ """调试日志"""
54
+ self.logger.debug(msg)
55
+
56
+ def info(self, msg: str) -> None:
57
+ """信息日志"""
58
+ self.logger.info(msg)
59
+
60
+ def warning(self, msg: str) -> None:
61
+ """警告日志"""
62
+ self.logger.warning(msg)
63
+
64
+ def error(self, msg: str) -> None:
65
+ """错误日志"""
66
+ self.logger.error(msg)
67
+
68
+ def critical(self, msg: str) -> None:
69
+ """严重错误日志"""
70
+ self.logger.critical(msg)
71
+
72
+
73
+ # 全局日志器实例
74
+ logger = LoggerManager()
app/models/grok_models.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+ # 模型配置字典
4
+ _MODEL_CONFIG = {
5
+ "grok-3-fast": {
6
+ "grok_model": ("grok-3", "MODEL_MODE_FAST"),
7
+ "rate_limit_model": "grok-3",
8
+ "cost": {"type": "low_cost", "multiplier": 1, "description": "计1次调用"},
9
+ "requires_super": False,
10
+ "display_name": "Grok 3 Fast",
11
+ "description": "Fast and efficient Grok 3 model",
12
+ "raw_model_path": "xai/grok-3",
13
+ "default_temperature": 1.0,
14
+ "default_max_output_tokens": 8192,
15
+ "supported_max_output_tokens": 131072,
16
+ "default_top_p": 0.95
17
+ },
18
+ "grok-4-fast": {
19
+ "grok_model": ("grok-4-mini-thinking-tahoe", "MODEL_MODE_GROK_4_MINI_THINKING"),
20
+ "rate_limit_model": "grok-4-mini-thinking-tahoe",
21
+ "cost": {"type": "low_cost", "multiplier": 1, "description": "计1次调用"},
22
+ "requires_super": False,
23
+ "display_name": "Grok 4 Fast",
24
+ "description": "Fast version of Grok 4 with mini thinking capabilities",
25
+ "raw_model_path": "xai/grok-4-mini-thinking-tahoe",
26
+ "default_temperature": 1.0,
27
+ "default_max_output_tokens": 8192,
28
+ "supported_max_output_tokens": 131072,
29
+ "default_top_p": 0.95
30
+ },
31
+ "grok-4-fast-expert": {
32
+ "grok_model": ("grok-4-mini-thinking-tahoe", "MODEL_MODE_EXPERT"),
33
+ "rate_limit_model": "grok-4-mini-thinking-tahoe",
34
+ "cost": {"type": "high_cost", "multiplier": 4, "description": "计4次调用"},
35
+ "requires_super": False,
36
+ "display_name": "Grok 4 Fast Expert",
37
+ "description": "Expert mode of Grok 4 Fast with enhanced reasoning",
38
+ "raw_model_path": "xai/grok-4-mini-thinking-tahoe",
39
+ "default_temperature": 1.0,
40
+ "default_max_output_tokens": 32768,
41
+ "supported_max_output_tokens": 131072,
42
+ "default_top_p": 0.95
43
+ },
44
+ "grok-4-expert": {
45
+ "grok_model": ("grok-4", "MODEL_MODE_EXPERT"),
46
+ "rate_limit_model": "grok-4",
47
+ "cost": {"type": "high_cost", "multiplier": 4, "description": "计4次调用"},
48
+ "requires_super": False,
49
+ "display_name": "Grok 4 Expert",
50
+ "description": "Full Grok 4 model with expert mode capabilities",
51
+ "raw_model_path": "xai/grok-4",
52
+ "default_temperature": 1.0,
53
+ "default_max_output_tokens": 32768,
54
+ "supported_max_output_tokens": 131072,
55
+ "default_top_p": 0.95
56
+ },
57
+ "grok-4-heavy": {
58
+ "grok_model": ("grok-4-heavy", "MODEL_MODE_HEAVY"),
59
+ "rate_limit_model": "grok-4-heavy",
60
+ "cost": {"type": "independent", "multiplier": 1, "description": "独立计费,只有Super用户可用"},
61
+ "requires_super": True,
62
+ "display_name": "Grok 4 Heavy",
63
+ "description": "Most powerful Grok 4 model with heavy computational capabilities. Requires Super Token for access.",
64
+ "raw_model_path": "xai/grok-4-heavy",
65
+ "default_temperature": 1.0,
66
+ "default_max_output_tokens": 65536,
67
+ "supported_max_output_tokens": 131072,
68
+ "default_top_p": 0.95
69
+ }
70
+ }
71
+
72
+ class TokenType(Enum):
73
+ """Token类型枚举"""
74
+ NORMAL = "ssoNormal" # 普通用户Token
75
+ SUPER = "ssoSuper" # 超级用户Token
76
+
77
+
78
+ class Models(Enum):
79
+ """支持的模型枚举"""
80
+ GROK_3_FAST = "grok-3-fast"
81
+ GROK_4_FAST = "grok-4-fast"
82
+ GROK_4_FAST_EXPERT = "grok-4-fast-expert"
83
+ GROK_4_EXPERT = "grok-4-expert"
84
+ GROK_4_HEAVY = "grok-4-heavy"
85
+
86
+ @classmethod
87
+ def get_model_info(cls, model: str) -> dict:
88
+ """获取模型的完整配置信息"""
89
+ return _MODEL_CONFIG.get(model, {})
90
+
91
+ @classmethod
92
+ def is_valid_model(cls, model: str) -> bool:
93
+ """检查模型是否有效"""
94
+ return model in _MODEL_CONFIG
95
+
96
+ @classmethod
97
+ def to_grok(cls, model: str) -> tuple[str, str]:
98
+ """转换为Grok内部模型名和模式类型"""
99
+ config = _MODEL_CONFIG.get(model)
100
+ if config:
101
+ return config["grok_model"]
102
+ return model, "MODEL_MODE_FAST"
103
+
104
+ @classmethod
105
+ def to_rate_limit(cls, model: str) -> str:
106
+ """转换为速率限制接口模型名"""
107
+ config = _MODEL_CONFIG.get(model)
108
+ return config["rate_limit_model"] if config else model
app/models/openai_schema.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI 请求-响应模型"""
2
+
3
+ from fastapi import HTTPException
4
+ from typing import Optional, List, Union, Dict, Any
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+
8
+ class OpenAIChatRequest(BaseModel):
9
+ """OpenAI 聊天请求模型"""
10
+
11
+ model: str = Field(..., description="模型名称", min_length=1)
12
+ messages: List[Dict[str, Any]] = Field(..., description="消息列表", min_length=1)
13
+ stream: bool = Field(False, description="启用流式响应")
14
+ temperature: Optional[float] = Field(0.7, ge=0, le=2, description="采样温度")
15
+ max_tokens: Optional[int] = Field(None, ge=1, le=100000, description="最大Token数")
16
+ top_p: Optional[float] = Field(1.0, ge=0, le=1, description="采样参数")
17
+
18
+ @field_validator('messages')
19
+ @classmethod
20
+ def validate_messages(cls, v):
21
+ """验证消息格式"""
22
+ if not v:
23
+ raise HTTPException(
24
+ status_code=400,
25
+ detail="消息列表不能为空"
26
+ )
27
+
28
+ for msg in v:
29
+ if not isinstance(msg, dict):
30
+ raise HTTPException(
31
+ status_code=400,
32
+ detail="每个消息必须是一个字典"
33
+ )
34
+ if 'role' not in msg:
35
+ raise HTTPException(
36
+ status_code=400,
37
+ detail="消息缺少必填字段 'role'"
38
+ )
39
+ if 'content' not in msg:
40
+ raise HTTPException(
41
+ status_code=400,
42
+ detail="消息缺少必填字段 'content'"
43
+ )
44
+ if msg['role'] not in ['system', 'user', 'assistant']:
45
+ raise HTTPException(
46
+ status_code=400,
47
+ detail=f"无效的角色 '{msg['role']}', 必须是 'system', 'user' 或 'assistant'"
48
+ )
49
+
50
+ return v
51
+
52
+ @field_validator('model')
53
+ @classmethod
54
+ def validate_model(cls, v):
55
+ """验证模型名称"""
56
+ allowed_models = [
57
+ 'grok-3-fast', 'grok-4-fast', 'grok-4-fast-expert',
58
+ 'grok-4-expert', 'grok-4-heavy'
59
+ ]
60
+ if v not in allowed_models:
61
+ raise HTTPException(
62
+ status_code=400,
63
+ detail=f"不支持的模型 '{v}', 支持的模型: {', '.join(allowed_models)}"
64
+ )
65
+ return v
66
+
67
+ class OpenAIChatCompletionMessage(BaseModel):
68
+ """聊天完成消息"""
69
+ role: str = Field(..., description="角色")
70
+ content: str = Field(..., description="消息内容")
71
+ reference_id: Optional[str] = Field(None, description="参考ID")
72
+ annotations: Optional[List[str]] = Field(None, description="注释")
73
+
74
+
75
+ class OpenAIChatCompletionChoice(BaseModel):
76
+ """聊天完成选项"""
77
+ index: int = Field(..., description="选项索引")
78
+ message: OpenAIChatCompletionMessage = Field(..., description="响应消息")
79
+ logprobs: Optional[float] = Field(None, description="对数概率")
80
+ finish_reason: str = Field("stop", description="完成原因")
81
+
82
+
83
+ class OpenAIChatCompletionResponse(BaseModel):
84
+ """聊天完成响应"""
85
+ id: str = Field(..., description="响应ID")
86
+ object: str = Field("chat.completion", description="对象类型")
87
+ created: int = Field(..., description="创建时间戳")
88
+ model: str = Field(..., description="使用的模型")
89
+ choices: List[OpenAIChatCompletionChoice] = Field(..., description="响应选项")
90
+ usage: Optional[Dict[str, Any]] = Field(None, description="令牌使用")
91
+
92
+
93
+ class OpenAIChatCompletionChunkMessage(BaseModel):
94
+ """流式响应消息片段"""
95
+ role: str = Field(..., description="角色")
96
+ content: str = Field(..., description="消息内容")
97
+
98
+
99
+ class OpenAIChatCompletionChunkChoice(BaseModel):
100
+ """流式响应选项"""
101
+ index: int = Field(..., description="选项索引")
102
+ delta: Optional[Union[Dict[str, Any], OpenAIChatCompletionChunkMessage]] = Field(
103
+ None, description="Delta数据"
104
+ )
105
+ finish_reason: Optional[str] = Field(None, description="完成原因")
106
+
107
+
108
+ class OpenAIChatCompletionChunkResponse(BaseModel):
109
+ """流式聊天完成响应"""
110
+ id: str = Field(..., description="响应ID")
111
+ object: str = Field("chat.completion.chunk", description="对象类型")
112
+ created: int = Field(..., description="创建时间戳")
113
+ model: str = Field(..., description="使用的模型")
114
+ system_fingerprint: Optional[str] = Field(None, description="系统指纹")
115
+ choices: List[OpenAIChatCompletionChunkChoice] = Field(..., description="响应选项")
app/services/grok/client.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grok API 客户端模块"""
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Dict, List, Tuple, Any
6
+ from curl_cffi import requests as curl_requests
7
+
8
+ from app.core.config import setting
9
+ from app.core.logger import logger
10
+ from app.models.grok_models import Models
11
+ from app.services.grok.processer import GrokResponseProcessor
12
+ from app.services.grok.statsig import get_dynamic_headers
13
+ from app.services.grok.token import token_manager
14
+ from app.services.grok.upload import ImageUploadManager
15
+ from app.core.exception import GrokApiException
16
+
17
+ # 常量定义
18
+ GROK_API_ENDPOINT = "https://grok.com/rest/app-chat/conversations/new"
19
+ REQUEST_TIMEOUT = 120
20
+ IMPERSONATE_BROWSER = "chrome133a"
21
+
22
+
23
+ class GrokClient:
24
+ """Grok API 客户端"""
25
+
26
+ @staticmethod
27
+ async def openai_to_grok(openai_request: dict):
28
+ """转换OpenAI请求为Grok请求并处理响应"""
29
+ model = openai_request["model"]
30
+ messages = openai_request["messages"]
31
+ stream = openai_request.get("stream", False)
32
+
33
+ logger.debug(f"[Client] 处理请求 - 模型:{model}, 消息数:{len(messages)}, 流式:{stream}")
34
+
35
+ # 提取消息内容和图片URL
36
+ content, image_urls = GrokClient._extract_content(messages)
37
+
38
+ # 获取认证令牌和模型信息
39
+ auth_token = token_manager.get_token(model)
40
+ model_name, model_mode = Models.to_grok(model)
41
+
42
+ # 上传图片并获取附件ID列表
43
+ image_attachments = await GrokClient._upload_imgs(image_urls, auth_token)
44
+
45
+ # 构建Grok请求载荷
46
+ payload = GrokClient._build_payload(content, model_name, model_mode, image_attachments)
47
+
48
+ return await GrokClient._send_request(payload, auth_token, model, stream)
49
+
50
+ @staticmethod
51
+ def _extract_content(messages: List[Dict]) -> Tuple[str, List[str]]:
52
+ """提取消息内容和图片URL"""
53
+ content_parts = []
54
+ image_urls = []
55
+
56
+ for msg in messages:
57
+ msg_content = msg.get("content", "")
58
+
59
+ # 处理复杂消息格式(包含文本和图片)
60
+ if isinstance(msg_content, list):
61
+ for item in msg_content:
62
+ item_type = item.get("type")
63
+ if item_type == "text":
64
+ content_parts.append(item.get("text", ""))
65
+ elif item_type == "image_url":
66
+ url = item.get("image_url", {}).get("url", "")
67
+ if url:
68
+ image_urls.append(url)
69
+ # 处理纯文本消息
70
+ else:
71
+ content_parts.append(msg_content)
72
+
73
+ return "".join(content_parts), image_urls
74
+
75
+ @staticmethod
76
+ async def _upload_imgs(image_urls: List[str], auth_token: str) -> List[str]:
77
+ """上传图片并返回附件ID列表"""
78
+ image_attachments = []
79
+ # 并发上传所有图片
80
+ tasks = [ImageUploadManager.upload(url, auth_token) for url in image_urls]
81
+ results = await asyncio.gather(*tasks, return_exceptions=True)
82
+
83
+ for url, result in zip(image_urls, results):
84
+ if isinstance(result, Exception):
85
+ logger.warning(f"[Client] 图片上传失败: {url}, 错误: {result}")
86
+ elif result:
87
+ image_attachments.append(result)
88
+
89
+ return image_attachments
90
+
91
+ @staticmethod
92
+ def _build_payload(content: str, model_name: str, model_mode: str, image_attachments: List[str]) -> Dict[str, Any]:
93
+ """构建Grok API请求载荷"""
94
+ return {
95
+ "temporary": setting.grok_config.get("temporary", True),
96
+ "modelName": model_name,
97
+ "message": content,
98
+ "fileAttachments": image_attachments,
99
+ "imageAttachments": [],
100
+ "disableSearch": False,
101
+ "enableImageGeneration": True,
102
+ "returnImageBytes": False,
103
+ "returnRawGrokInXaiRequest": False,
104
+ "enableImageStreaming": True,
105
+ "imageGenerationCount": 2,
106
+ "forceConcise": False,
107
+ "toolOverrides": {},
108
+ "enableSideBySide": True,
109
+ "sendFinalMetadata": True,
110
+ "isReasoning": False,
111
+ "webpageUrls": [],
112
+ "disableTextFollowUps": True,
113
+ "responseMetadata": {"requestModelDetails": {"modelId": model_name}},
114
+ "disableMemory": False,
115
+ "forceSideBySide": False,
116
+ "modelMode": model_mode,
117
+ "isAsyncChat": False
118
+ }
119
+
120
+ @staticmethod
121
+ async def _send_request(payload: dict, auth_token: str, model: str, stream: bool):
122
+ """发送HTTP请求到Grok API"""
123
+ # 验证认证令牌
124
+ if not auth_token:
125
+ raise GrokApiException("认证令牌缺失", "NO_AUTH_TOKEN")
126
+
127
+ try:
128
+ # 构建请求头和代理
129
+ headers = GrokClient._build_headers(auth_token)
130
+ proxies = GrokClient._get_proxy()
131
+
132
+ # 在线程池中执行同步HTTP请求,���免阻塞事件循环
133
+ response = await asyncio.to_thread(
134
+ curl_requests.post,
135
+ GROK_API_ENDPOINT,
136
+ headers=headers,
137
+ data=json.dumps(payload),
138
+ impersonate=IMPERSONATE_BROWSER,
139
+ timeout=REQUEST_TIMEOUT,
140
+ stream=True,
141
+ **proxies
142
+ )
143
+
144
+ logger.debug(f"[Client] API响应状态码: {response.status_code}")
145
+
146
+ # 处理非成功响应
147
+ if response.status_code != 200:
148
+ GrokClient._handle_error(response, auth_token)
149
+
150
+ # 请求成功,重置失败计数
151
+ asyncio.create_task(token_manager.reset_failure(auth_token))
152
+
153
+ # 处理并返回响应
154
+ return await GrokClient._process_response(response, auth_token, model, stream)
155
+
156
+ except curl_requests.RequestsError as e:
157
+ raise GrokApiException(f"网络错误: {e}", "NETWORK_ERROR") from e
158
+ except json.JSONDecodeError as e:
159
+ raise GrokApiException(f"JSON解析错误: {e}", "JSON_ERROR") from e
160
+
161
+ @staticmethod
162
+ def _build_headers(auth_token: str) -> Dict[str, str]:
163
+ """构建请求头"""
164
+ headers = get_dynamic_headers("/rest/app-chat/conversations/new")
165
+
166
+ # 构建Cookie
167
+ cf_clearance = setting.grok_config.get("cf_clearance", "")
168
+ headers["Cookie"] = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
169
+
170
+ return headers
171
+
172
+ @staticmethod
173
+ def _get_proxy() -> Dict[str, str]:
174
+ """获取代理配置"""
175
+ proxy_url = setting.grok_config.get("proxy_url", "")
176
+ if proxy_url:
177
+ return {"http": proxy_url, "https": proxy_url}
178
+ return {}
179
+
180
+ @staticmethod
181
+ def _handle_error(response, auth_token: str):
182
+ """处理错误响应"""
183
+ try:
184
+ error_data = response.json()
185
+ error_message = str(error_data)
186
+ except Exception:
187
+ error_data = response.text
188
+ error_message = error_data[:200] if error_data else "未知错误"
189
+
190
+ # 记录Token失败
191
+ asyncio.create_task(token_manager.record_failure(auth_token, response.status_code, error_message))
192
+
193
+ raise GrokApiException(
194
+ f"请求失败: {response.status_code} - {error_message}",
195
+ "HTTP_ERROR",
196
+ {"status": response.status_code, "data": error_data}
197
+ )
198
+
199
+ @staticmethod
200
+ async def _process_response(response, auth_token: str, model: str, stream: bool):
201
+ """处理API响应"""
202
+ if stream:
203
+ result = GrokResponseProcessor.process_stream(response, auth_token)
204
+ asyncio.create_task(GrokClient._update_rate_limits(auth_token, model))
205
+ else:
206
+ result = await GrokResponseProcessor.process_normal(response, auth_token)
207
+ asyncio.create_task(GrokClient._update_rate_limits(auth_token, model))
208
+
209
+ return result
210
+
211
+ @staticmethod
212
+ async def _update_rate_limits(auth_token: str, model: str):
213
+ """异步更新速率限制信息"""
214
+ try:
215
+ await token_manager.check_limits(auth_token, model)
216
+ except Exception as e:
217
+ logger.error(f"[Client] 更新速率限制失败: {e}")
app/services/grok/image_cache.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """图片缓存服务模块"""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from curl_cffi.requests import AsyncSession
7
+
8
+ from app.core.config import setting
9
+ from app.core.logger import logger
10
+ from app.services.grok.statsig import get_dynamic_headers
11
+
12
+
13
+ class ImageCacheService:
14
+ """图片缓存服务"""
15
+
16
+ def __init__(self):
17
+ self.cache_dir = Path("data/temp")
18
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
19
+
20
+ @staticmethod
21
+ def _get_cache_filename(image_path: str) -> str:
22
+ """将图片路径转换为缓存文件名"""
23
+ # 移除开头的斜杠并替换所有斜杠为短横线
24
+ filename = image_path.lstrip('/').replace('/', '-')
25
+ return filename
26
+
27
+ def _get_cache_path(self, image_path: str) -> Path:
28
+ """获取缓存文件的完整路径"""
29
+ filename = self._get_cache_filename(image_path)
30
+ return self.cache_dir / filename
31
+
32
+ async def download_image(self, image_path: str, auth_token: str) -> Optional[Path]:
33
+ """下载并缓存图片
34
+
35
+ Args:
36
+ image_path: 图片路径,如 /users/xxx/generated/xxx/image.jpg
37
+ auth_token: 认证令牌
38
+
39
+ Returns:
40
+ 缓存文件路径,下载失败返回 None
41
+ """
42
+ cache_path = self._get_cache_path(image_path)
43
+
44
+ if cache_path.exists():
45
+ logger.debug(f"[ImageCache] 图片已缓存: {cache_path}")
46
+ return cache_path
47
+
48
+ image_url = f"https://assets.grok.com{image_path}"
49
+
50
+ try:
51
+ # 构建 Cookie
52
+ cf_clearance = setting.grok_config.get("cf_clearance", "")
53
+ cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
54
+
55
+ # 构建请求头
56
+ headers = {
57
+ **get_dynamic_headers(pathname=image_path),
58
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
59
+ "Sec-Fetch-Dest": "document",
60
+ "Sec-Fetch-Mode": "navigate",
61
+ "Sec-Fetch-Site": "same-site",
62
+ "Sec-Fetch-User": "?1",
63
+ "Upgrade-Insecure-Requests": "1",
64
+ "Referer": "https://grok.com/",
65
+ "Cookie": cookie
66
+ }
67
+
68
+ # 代理配置
69
+ proxy_url = setting.grok_config.get("proxy_url")
70
+ proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
71
+
72
+ async with AsyncSession() as session:
73
+ logger.debug(f"[ImageCache] 开始下载图片: {image_url}")
74
+ response = await session.get(
75
+ image_url,
76
+ headers=headers,
77
+ proxies=proxies,
78
+ timeout=30.0,
79
+ allow_redirects=True,
80
+ impersonate="chrome133a"
81
+ )
82
+ response.raise_for_status()
83
+
84
+ cache_path.write_bytes(response.content)
85
+ logger.debug(f"[ImageCache] 图片已缓存: {cache_path} ({len(response.content)} bytes)")
86
+
87
+ asyncio.create_task(self.cleanup_cache())
88
+
89
+ return cache_path
90
+
91
+ except Exception as e:
92
+ logger.error(f"[ImageCache] 下载图片失败: {e}")
93
+ return None
94
+
95
+ def get_cached_image(self, image_path: str) -> Optional[Path]:
96
+ """获取缓存的图片路径
97
+
98
+ Args:
99
+ image_path: 图片路径
100
+
101
+ Returns:
102
+ 缓存文件路径,不存在返回 None
103
+ """
104
+ cache_path = self._get_cache_path(image_path)
105
+ return cache_path if cache_path.exists() else None
106
+
107
+ async def cleanup_cache(self):
108
+ """清理缓存目录,确保不超过配置的大小限制"""
109
+ try:
110
+ # 获取配置的最大缓存大小(MB)
111
+ max_size_mb = setting.global_config.get("temp_max_size_mb", 500)
112
+ max_size_bytes = max_size_mb * 1024 * 1024
113
+
114
+ # 获取所有缓存文件及其大小和修改时间
115
+ files = []
116
+ total_size = 0
117
+
118
+ for file_path in self.cache_dir.glob("*"):
119
+ if file_path.is_file():
120
+ size = file_path.stat().st_size
121
+ mtime = file_path.stat().st_mtime
122
+ files.append((file_path, size, mtime))
123
+ total_size += size
124
+
125
+ # 如果总大小未超限,无需清理
126
+ if total_size <= max_size_bytes:
127
+ logger.debug(f"[ImageCache] 缓存大小 {total_size / 1024 / 1024:.2f}MB,未超限")
128
+ return
129
+
130
+ logger.info(f"[ImageCache] 缓存大小 {total_size / 1024 / 1024:.2f}MB 超过限制 {max_size_mb}MB,开始清理")
131
+
132
+ # 按修改时间排序(最旧的在前)
133
+ files.sort(key=lambda x: x[2])
134
+
135
+ # 删除最旧的文件直到总大小低于限制
136
+ for file_path, size, _ in files:
137
+ if total_size <= max_size_bytes:
138
+ break
139
+
140
+ file_path.unlink()
141
+ total_size -= size
142
+ logger.debug(f"[ImageCache] 已删除缓存文件: {file_path}")
143
+
144
+ logger.info(f"[ImageCache] 缓存清理完成,当前大小 {total_size / 1024 / 1024:.2f}MB")
145
+
146
+ except Exception as e:
147
+ logger.error(f"[ImageCache] 清理缓存失败: {e}")
148
+
149
+
150
+ # 全局实例
151
+ image_cache_service = ImageCacheService()
app/services/grok/processer.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grok API 响应处理器模块"""
2
+
3
+ import json
4
+ import uuid
5
+ import time
6
+ from typing import Iterator
7
+
8
+ from app.core.config import setting
9
+ from app.core.exception import GrokApiException
10
+ from app.core.logger import logger
11
+ from app.models.openai_schema import (
12
+ OpenAIChatCompletionResponse,
13
+ OpenAIChatCompletionChoice,
14
+ OpenAIChatCompletionMessage,
15
+ OpenAIChatCompletionChunkResponse,
16
+ OpenAIChatCompletionChunkChoice,
17
+ OpenAIChatCompletionChunkMessage
18
+ )
19
+ from app.services.grok.image_cache import image_cache_service
20
+
21
+
22
+ class GrokResponseProcessor:
23
+ """Grok API 响应处理器"""
24
+
25
+ @staticmethod
26
+ async def process_normal(response, auth_token: str) -> OpenAIChatCompletionResponse:
27
+ """处理非流式响应"""
28
+ try:
29
+ for chunk in response.iter_lines():
30
+ if not chunk:
31
+ continue
32
+
33
+ data = json.loads(chunk.decode("utf-8"))
34
+
35
+ # 错误检查
36
+ if error := data.get("error"):
37
+ raise GrokApiException(
38
+ f"API错误: {error.get('message', '未知错误')}",
39
+ "API_ERROR",
40
+ {"code": error.get("code")}
41
+ )
42
+
43
+ # 提取模型响应
44
+ model_response = data.get("result", {}).get("response", {}).get("modelResponse")
45
+ if not model_response:
46
+ continue
47
+
48
+ # 检查 modelResponse 中的错误
49
+ if error_msg := model_response.get("error"):
50
+ raise GrokApiException(
51
+ f"模型响应错误: {error_msg}",
52
+ "MODEL_ERROR"
53
+ )
54
+
55
+ # 构建响应内容
56
+ model = model_response.get("model")
57
+ content = model_response.get("message", "")
58
+
59
+ # 添加生成的图片
60
+ if images := model_response.get("generatedImageUrls"):
61
+ for img in images:
62
+ try:
63
+ cache_path = await image_cache_service.download_image(f"/{img}", auth_token)
64
+ if cache_path:
65
+ img_path = img.replace('/', '-')
66
+ base_url = setting.global_config.get("base_url", "")
67
+ img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
68
+ content += f"\n![Generated Image]({img_url})"
69
+ else:
70
+ content += f"\n![Generated Image](https://assets.grok.com/{img})"
71
+ except Exception as e:
72
+ logger.warning(f"[Processor] 缓存图片失败: {e}")
73
+ content += f"\n![Generated Image](https://assets.grok.com/{img})"
74
+
75
+ # 返回OpenAI格式响应
76
+ result = OpenAIChatCompletionResponse(
77
+ id=f"chatcmpl-{uuid.uuid4()}",
78
+ object="chat.completion",
79
+ created=int(time.time()),
80
+ model=model,
81
+ choices=[OpenAIChatCompletionChoice(
82
+ index=0,
83
+ message=OpenAIChatCompletionMessage(
84
+ role="assistant",
85
+ content=content
86
+ ),
87
+ finish_reason="stop"
88
+ )],
89
+ usage=None
90
+ )
91
+ response.close()
92
+ return result
93
+
94
+ raise GrokApiException("无响应数据", "NO_RESPONSE")
95
+
96
+ except json.JSONDecodeError as e:
97
+ raise GrokApiException(f"JSON解析失败: {e}", "JSON_ERROR") from e
98
+ finally:
99
+ # 确保响应对象被关闭
100
+ if hasattr(response, 'close'):
101
+ response.close()
102
+
103
+ @staticmethod
104
+ async def process_stream(response, auth_token: str) -> Iterator[str]:
105
+ """处理流式响应"""
106
+ is_image = False
107
+ is_thinking = False
108
+ thinking_finished = False
109
+ chunk_index = 0
110
+ model = None
111
+ filtered_tags = setting.grok_config.get("filtered_tags", "").split(",")
112
+
113
+ def make_chunk(content: str, finish: str = None):
114
+ """生成OpenAI格式的响应块"""
115
+ chunk_data = OpenAIChatCompletionChunkResponse(
116
+ id=f"chatcmpl-{uuid.uuid4()}",
117
+ created=int(time.time()),
118
+ model=model or "grok-4-mini-thinking-tahoe",
119
+ choices=[OpenAIChatCompletionChunkChoice(
120
+ index=chunk_index,
121
+ delta=OpenAIChatCompletionChunkMessage(
122
+ role="assistant",
123
+ content=content
124
+ ) if content else {},
125
+ finish_reason=finish
126
+ )]
127
+ ).model_dump()
128
+ # SSE 格式返回
129
+ return f"data: {json.dumps(chunk_data)}\n\n"
130
+
131
+ try:
132
+ for chunk in response.iter_lines():
133
+ logger.debug(f"[Processor] 接收到数据块: {len(chunk)} bytes")
134
+ if not chunk:
135
+ continue
136
+
137
+ try:
138
+ data = json.loads(chunk.decode("utf-8"))
139
+
140
+ # 错误检查
141
+ if error := data.get("error"):
142
+ error_msg = error.get('message', '未知错误')
143
+ logger.error(f"[Processor] Grok API返回错误: {error_msg}")
144
+ yield make_chunk(f"Error: {error_msg}", "stop")
145
+ yield "data: [DONE]\n\n"
146
+ return
147
+
148
+ # 提取响应数据
149
+ grok_resp = data.get("result", {}).get("response", {})
150
+ logger.debug(f"[Processor] 解析响应数据: {len(grok_resp)} 字段")
151
+ if not grok_resp:
152
+ continue
153
+
154
+ # 更新模型名称
155
+ if user_resp := grok_resp.get("userResponse"):
156
+ if m := user_resp.get("model"):
157
+ model = m
158
+
159
+ # 检查生成模式
160
+ if grok_resp.get("imageAttachmentInfo"):
161
+ is_image = True
162
+
163
+ # 获取token
164
+ token = grok_resp.get("token", "")
165
+
166
+ # 图片模式
167
+ if is_image:
168
+ if model_resp := grok_resp.get("modelResponse"):
169
+ # 生成图片链接并缓存
170
+ content = ""
171
+ for img in model_resp.get("generatedImageUrls", []):
172
+ try:
173
+ # 异步下载并缓存图片
174
+ await image_cache_service.download_image(f"/{img}", auth_token)
175
+ # 使用本地缓存路径
176
+ img_path = img.replace('/', '-')
177
+ base_url = setting.global_config.get("base_url", "")
178
+ img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
179
+ content += f"![Generated Image]({img_url})\n"
180
+ except Exception as e:
181
+ logger.warning(f"[Processor] 缓存图片失败: {e}")
182
+ content += f"![Generated Image](https://assets.grok.com/{img})\n"
183
+ yield make_chunk(content.strip(), "stop")
184
+ return
185
+ elif token:
186
+ yield make_chunk(token)
187
+ chunk_index += 1
188
+
189
+ # 对话模式
190
+ else:
191
+ # 过滤 list 格式的 token
192
+ if isinstance(token, list):
193
+ continue
194
+
195
+ # 过滤特定标签
196
+ if any(tag in token for tag in filtered_tags if token):
197
+ continue
198
+
199
+ # 获取当前状态
200
+ current_is_thinking = grok_resp.get("isThinking", False)
201
+ message_tag = grok_resp.get("messageTag")
202
+
203
+ # 跳过后续的 thinking
204
+ if thinking_finished and current_is_thinking:
205
+ continue
206
+
207
+ # 检查 toolUsageCardId
208
+ if grok_resp.get("toolUsageCardId"):
209
+ if web_search := grok_resp.get("webSearchResults"):
210
+ if current_is_thinking:
211
+ # 添加搜索结果到 token
212
+ for result in web_search.get("results", []):
213
+ title = result.get("title", "")
214
+ url = result.get("url", "")
215
+ preview = result.get("preview", "")
216
+ preview_clean = preview.replace("\n", "") if isinstance(preview, str) else ""
217
+ token += f'\n- [{title}]({url} "{preview_clean}")'
218
+ token += "\n"
219
+ else:
220
+ # 有 webSearchResults 但 isThinking 为 false
221
+ continue
222
+ else:
223
+ # 没有 webSearchResults
224
+ continue
225
+
226
+ if token:
227
+ content = token
228
+
229
+ # header 在 token 后换行
230
+ if message_tag == "header":
231
+ content = f"\n\n{token}\n\n"
232
+
233
+ # is_thinking 状态切换
234
+ if not is_thinking and current_is_thinking:
235
+ content = f"<think>\n{content}"
236
+ elif is_thinking and not current_is_thinking:
237
+ content = f"\n</think>\n{content}"
238
+ thinking_finished = True
239
+
240
+ yield make_chunk(content)
241
+ chunk_index += 1
242
+ is_thinking = current_is_thinking
243
+
244
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
245
+ logger.warning(f"[Processor] 解析chunk失败: {e}")
246
+ continue
247
+ except Exception as e:
248
+ logger.warning(f"[Processor] 处理chunk出错: {e}")
249
+ continue
250
+
251
+ # 发送结束块
252
+ yield make_chunk("", "stop")
253
+ # 发送流结束标记
254
+ yield "data: [DONE]\n\n"
255
+
256
+ except Exception as e:
257
+ logger.error(f"[Processor] 流式处理严重错误: {e}")
258
+ yield make_chunk(f"处理错误: {e}", "error")
259
+ # 发送流结束标记
260
+ yield "data: [DONE]\n\n"
app/services/grok/statsig.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grok 请求头管理模块"""
2
+
3
+ import uuid
4
+ from typing import Dict
5
+
6
+ from app.core.config import setting
7
+
8
+
9
+ def get_dynamic_headers(pathname: str = "/rest/app-chat/conversations/new") -> Dict[str, str]:
10
+ """获取请求头
11
+
12
+ Args:
13
+ pathname: 请求路径
14
+
15
+ Returns:
16
+ 请求头字典
17
+ """
18
+ # 获取配置的 x-statsig-id
19
+ statsig_id = setting.grok_config.get("x_statsig_id")
20
+ if not statsig_id:
21
+ raise ValueError("配置文件中未设置 x_statsig_id")
22
+
23
+ # 构建基础请求头
24
+ headers = {
25
+ "Accept": "*/*",
26
+ "Accept-Language": "zh-CN,zh;q=0.9",
27
+ "Accept-Encoding": "gzip, deflate, br, zstd",
28
+ "Content-Type": "application/json" if "upload-file" not in pathname else "text/plain;charset=UTF-8",
29
+ "Connection": "keep-alive",
30
+ "Origin": "https://grok.com",
31
+ "Priority": "u=1, i",
32
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
33
+ "Sec-Ch-Ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"',
34
+ "Sec-Ch-Ua-Mobile": "?0",
35
+ "Sec-Ch-Ua-Platform": '"macOS"',
36
+ "Sec-Fetch-Dest": "empty",
37
+ "Sec-Fetch-Mode": "cors",
38
+ "Sec-Fetch-Site": "same-origin",
39
+ "Baggage": "sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c",
40
+ "x-statsig-id": statsig_id,
41
+ "x-xai-request-id": str(uuid.uuid4())
42
+ }
43
+
44
+ return headers
app/services/grok/token.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grok Token 管理器模块"""
2
+
3
+ import json
4
+ import time
5
+ import asyncio
6
+ import aiofiles
7
+ from pathlib import Path
8
+ from curl_cffi.requests import AsyncSession
9
+ from typing import Dict, Any, Optional, Tuple
10
+
11
+ from app.models.grok_models import TokenType, Models
12
+ from app.core.exception import GrokApiException
13
+ from app.core.logger import logger
14
+ from app.core.config import setting
15
+ from app.services.grok.statsig import get_dynamic_headers
16
+
17
+ # 常量定义
18
+ RATE_LIMIT_ENDPOINT = "https://grok.com/rest/rate-limits"
19
+ REQUEST_TIMEOUT = 30
20
+ IMPERSONATE_BROWSER = "chrome133a"
21
+ MAX_FAILURE_COUNT = 3
22
+ TOKEN_INVALID_CODE = 401 # SSO Token失效
23
+ STATSIG_INVALID_CODE = 403 # x-statsig-id失效
24
+
25
+
26
+ class GrokTokenManager:
27
+ """
28
+ Grok Token管理器
29
+
30
+ 单例模式的Token管理器,负责:
31
+ - Token文件的读写操作
32
+ - Token负载均衡
33
+ - Token状态管理
34
+ - 支持普通Token和Super Token
35
+ """
36
+
37
+ _instance: Optional['GrokTokenManager'] = None
38
+ _lock = asyncio.Lock()
39
+
40
+ def __new__(cls) -> 'GrokTokenManager':
41
+ """单例模式实现"""
42
+ if cls._instance is None:
43
+ cls._instance = super().__new__(cls)
44
+ return cls._instance
45
+
46
+ def __init__(self):
47
+ """初始化Token管理器"""
48
+ if hasattr(self, '_initialized'):
49
+ return
50
+
51
+ self.token_file = Path(__file__).parents[3] / "data" / "token.json"
52
+ self._file_lock = asyncio.Lock()
53
+ self.token_file.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+ # 同步加载初始数据
56
+ self._load_data()
57
+ self._initialized = True
58
+
59
+ logger.debug(f"[Token] 管理器初始化完成,文件: {self.token_file}")
60
+
61
+ def _load_data(self) -> None:
62
+ """同步加载Token数据(仅用于初始化)"""
63
+ default_data = {
64
+ TokenType.NORMAL.value: {},
65
+ TokenType.SUPER.value: {}
66
+ }
67
+
68
+ try:
69
+ if self.token_file.exists():
70
+ with open(self.token_file, "r", encoding="utf-8") as f:
71
+ self.token_data = json.load(f)
72
+ else:
73
+ self.token_data = default_data
74
+ logger.debug("[Token] 创建新的Token数据文件")
75
+ except (json.JSONDecodeError, IOError) as e:
76
+ logger.error(f"[Token] 加载Token数据失败: {str(e)}")
77
+ self.token_data = default_data
78
+
79
+ async def _save_data(self) -> None:
80
+ """异步保存Token数据到文件"""
81
+ try:
82
+ async with self._file_lock:
83
+ async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
84
+ await f.write(json.dumps(self.token_data, indent=2, ensure_ascii=False))
85
+ except IOError as e:
86
+ logger.error(f"[Token] 保存Token数据失败: {str(e)}")
87
+ raise GrokApiException(
88
+ f"Token数据保存失败: {str(e)}",
89
+ "TOKEN_SAVE_ERROR",
90
+ {"file_path": str(self.token_file)}
91
+ )
92
+
93
+ @staticmethod
94
+ def _extract_sso(auth_token: str) -> Optional[str]:
95
+ """从认证令牌中提取SSO值"""
96
+ if "sso=" in auth_token:
97
+ return auth_token.split("sso=")[1].split(";")[0]
98
+ logger.warning("[Token] 无法从认证令牌中提取SSO值")
99
+ return None
100
+
101
+ def _find_token(self, sso_value: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
102
+ """查找Token数据,返回(token_type, token_data)"""
103
+ for token_type in [TokenType.NORMAL.value, TokenType.SUPER.value]:
104
+ if sso_value in self.token_data[token_type]:
105
+ return token_type, self.token_data[token_type][sso_value]
106
+ return None, None
107
+
108
+ async def add_token(self, tokens: list[str], token_type: TokenType) -> None:
109
+ """添加Token到管理器"""
110
+ if not tokens:
111
+ logger.debug("[Token] 尝试添加空的Token列表")
112
+ return
113
+
114
+ added_count = 0
115
+ for token in tokens:
116
+ if not token or not token.strip():
117
+ logger.debug("[Token] 跳过空的Token")
118
+ continue
119
+
120
+ self.token_data[token_type.value][token] = {
121
+ "createdTime": int(time.time() * 1000),
122
+ "remainingQueries": -1,
123
+ "heavyremainingQueries": -1,
124
+ "status": "active",
125
+ "failedCount": 0,
126
+ "lastFailureTime": None,
127
+ "lastFailureReason": None
128
+ }
129
+ added_count += 1
130
+
131
+ await self._save_data()
132
+ logger.info(f"[Token] 成功添加 {added_count} 个 {token_type.value} Token")
133
+
134
+ async def delete_token(self, tokens: list[str], token_type: TokenType) -> None:
135
+ """删除指定的Token"""
136
+ if not tokens:
137
+ logger.debug("[Token] 尝试删除空的Token列表")
138
+ return
139
+
140
+ deleted_count = 0
141
+ for token in tokens:
142
+ if token in self.token_data[token_type.value]:
143
+ del self.token_data[token_type.value][token]
144
+ deleted_count += 1
145
+ else:
146
+ logger.debug(f"[Token] Token不存在: {token[:10]}...")
147
+
148
+ await self._save_data()
149
+ logger.info(f"[Token] 成功删除 {deleted_count} 个 {token_type.value} Token")
150
+
151
+ def get_tokens(self) -> Dict[str, Any]:
152
+ """获取所有Token数据"""
153
+ return self.token_data.copy()
154
+
155
+ def get_token(self, model: str) -> str:
156
+ """获取指定模型的Token"""
157
+ jwt_token = self.select_token(model)
158
+ return f"sso-rw={jwt_token};sso={jwt_token}"
159
+
160
+ def select_token(self, model: str) -> str:
161
+ """根据模型类型和剩余次数选择最优Token"""
162
+ def select_best_token(tokens_dict: Dict[str, Any]) -> Tuple[Optional[str], Optional[int]]:
163
+ """从 token 字典中选择最佳 token"""
164
+ unused_tokens = [] # remaining = -1 的 token
165
+ used_tokens = [] # remaining > 0 的 token
166
+
167
+ for token_key, token_data in tokens_dict.items():
168
+ # 跳过已失效的Token
169
+ if token_data.get("status") == "expired":
170
+ continue
171
+
172
+ remaining = int(token_data.get(remaining_field, -1))
173
+
174
+ # 跳过已限流的 token
175
+ if remaining == 0:
176
+ continue
177
+
178
+ # 分类存储
179
+ if remaining == -1:
180
+ unused_tokens.append(token_key)
181
+ elif remaining > 0:
182
+ used_tokens.append((token_key, remaining))
183
+
184
+ # 优先返回尚未使用的 token
185
+ if unused_tokens:
186
+ return unused_tokens[0], -1
187
+
188
+ # 否则返回次数最多的 token
189
+ if used_tokens:
190
+ used_tokens.sort(key=lambda x: x[1], reverse=True)
191
+ return used_tokens[0][0], used_tokens[0][1]
192
+
193
+ return None, None
194
+
195
+ max_token_key = None
196
+ max_remaining = None
197
+
198
+ # 深拷贝
199
+ token_data_snapshot = {
200
+ TokenType.NORMAL.value: self.token_data[TokenType.NORMAL.value].copy(),
201
+ TokenType.SUPER.value: self.token_data[TokenType.SUPER.value].copy()
202
+ }
203
+
204
+ if model == "grok-4-heavy":
205
+ # grok-4-heavy 只能使用Super Token + heavy remaining queries
206
+ remaining_field = "heavyremainingQueries"
207
+ max_token_key, max_remaining = select_best_token(token_data_snapshot[TokenType.SUPER.value])
208
+ else:
209
+ # 其他模型使用 remaining Queries
210
+ remaining_field = "remainingQueries"
211
+
212
+ # 优先使用普通Token
213
+ max_token_key, max_remaining = select_best_token(token_data_snapshot[TokenType.NORMAL.value])
214
+
215
+ # 如果普通Token没有可用的,尝试使用Super Token
216
+ if max_token_key is None:
217
+ max_token_key, max_remaining = select_best_token(token_data_snapshot[TokenType.SUPER.value])
218
+
219
+ if max_token_key is None:
220
+ raise GrokApiException(
221
+ f"没有可用Token用于模型 {model}",
222
+ "NO_AVAILABLE_TOKEN",
223
+ {
224
+ "model": model,
225
+ "normal_count": len(token_data_snapshot[TokenType.NORMAL.value]),
226
+ "super_count": len(token_data_snapshot[TokenType.SUPER.value])
227
+ }
228
+ )
229
+
230
+ status_text = "未使用" if max_remaining == -1 else f"剩余{max_remaining}次"
231
+ logger.debug(f"[Token] 为模型 {model} 选择Token ({status_text})")
232
+ return max_token_key
233
+
234
+ async def check_limits(self, auth_token: str, model: str) -> Optional[Dict[str, Any]]:
235
+ """检查并更新模型速率限制"""
236
+ try:
237
+ rate_limit_model_name = Models.to_rate_limit(model)
238
+ logger.debug(f"[Token] 检查模型 {model} (接口模型: {rate_limit_model_name}) 的速率限制")
239
+
240
+ # 准备请求
241
+ payload = {"requestKind": "DEFAULT", "modelName": rate_limit_model_name}
242
+ cf_clearance = setting.grok_config.get("cf_clearance", "")
243
+ cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
244
+
245
+ headers = get_dynamic_headers("/rest/rate-limits")
246
+ headers["Cookie"] = cookie
247
+
248
+ # 获取代理配置
249
+ proxy_url = setting.grok_config.get("proxy_url", "")
250
+ proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
251
+
252
+ # 发送异步请求
253
+ async with AsyncSession() as session:
254
+ response = await session.post(
255
+ RATE_LIMIT_ENDPOINT,
256
+ headers=headers,
257
+ json=payload,
258
+ impersonate=IMPERSONATE_BROWSER,
259
+ timeout=REQUEST_TIMEOUT,
260
+ proxies=proxies
261
+ )
262
+
263
+ if response.status_code == 200:
264
+ rate_limit_data = response.json()
265
+ logger.debug(f"[Token] 成功获取速率限制信息")
266
+
267
+ # 保存速率限制信息
268
+ sso_value = self._extract_sso(auth_token)
269
+ if sso_value:
270
+ if model == "grok-4-heavy":
271
+ await self.update_limits(sso_value, normal=None, heavy=rate_limit_data.get("remainingQueries", -1))
272
+ logger.info(f"[Token] 已更新限制: sso={sso_value[:10]}..., heavy={rate_limit_data.get('remainingQueries', -1)}")
273
+ else:
274
+ await self.update_limits(sso_value, normal=rate_limit_data.get("remainingTokens", -1), heavy=None)
275
+ logger.info(f"[Token] 已更新限制: sso={sso_value[:10]}..., 通用={rate_limit_data.get('remainingTokens', -1)}")
276
+
277
+ return rate_limit_data
278
+ else:
279
+ logger.warning(f"[Token] 获取速率限制失败,状态码: {response.status_code}")
280
+ return None
281
+
282
+ except Exception as e:
283
+ logger.error(f"[Token] 检查速率限制时发生错误: {str(e)}")
284
+ return None
285
+
286
+ async def update_limits(self, sso_value: str, normal: Optional[int] = None, heavy: Optional[int] = None) -> None:
287
+ """更新Token限制信息"""
288
+ try:
289
+ for token_type in [TokenType.NORMAL.value, TokenType.SUPER.value]:
290
+ if sso_value in self.token_data[token_type]:
291
+ if normal is not None:
292
+ self.token_data[token_type][sso_value]["remainingQueries"] = normal
293
+ if heavy is not None:
294
+ self.token_data[token_type][sso_value]["heavyremainingQueries"] = heavy
295
+
296
+ await self._save_data()
297
+ logger.info(f"[Token] 已更新Token {sso_value[:10]}... 的限制信息")
298
+ return
299
+
300
+ logger.warning(f"[Token] 未找到SSO值为 {sso_value[:10]}... 的Token")
301
+
302
+ except Exception as e:
303
+ logger.error(f"[Token] 更新Token限制时发生错误: {str(e)}")
304
+
305
+ async def record_failure(self, auth_token: str, status_code: int, error_message: str) -> None:
306
+ """记录Token失败信息
307
+
308
+ 错误码说明:
309
+ - 401: SSO Token失效,会标记Token为expired
310
+ - 403: x-statsig-id失效,不影响Token状态
311
+
312
+ Args:
313
+ auth_token: 完整的认证Token (格式: sso-rw=xxx;sso=xxx)
314
+ status_code: HTTP状态码
315
+ error_message: 错误信息
316
+ """
317
+ try:
318
+ # 403错误是x-statsig-id失效,不是Token问题
319
+ if status_code == STATSIG_INVALID_CODE:
320
+ logger.warning(f"[Token] x-statsig-id失效 (403),需要更新配置文件中的x_statsig_id")
321
+ return
322
+
323
+ sso_value = self._extract_sso(auth_token)
324
+ if not sso_value:
325
+ return
326
+
327
+ _, token_data = self._find_token(sso_value)
328
+ if not token_data:
329
+ logger.warning(f"[Token] 未找到SSO值为 {sso_value[:10]}... 的Token")
330
+ return
331
+
332
+ # 更新失败计数
333
+ token_data["failedCount"] = token_data.get("failedCount", 0) + 1
334
+ token_data["lastFailureTime"] = int(time.time() * 1000)
335
+ token_data["lastFailureReason"] = f"{status_code}: {error_message}"
336
+
337
+ logger.warning(
338
+ f"[Token] Token {sso_value[:10]}... 失败 (状态码: {status_code}), "
339
+ f"失败次数: {token_data['failedCount']}/{MAX_FAILURE_COUNT}, "
340
+ f"原因: {error_message}"
341
+ )
342
+
343
+ # 只有401错误(SSO Token失效)且失败次数达到上限时,标记为失效
344
+ if status_code == TOKEN_INVALID_CODE and token_data["failedCount"] >= MAX_FAILURE_COUNT:
345
+ token_data["status"] = "expired"
346
+ logger.error(
347
+ f"[Token] SSO Token {sso_value[:10]}... 已被标记为失效 "
348
+ f"(连续401错误{token_data['failedCount']}次)"
349
+ )
350
+
351
+ await self._save_data()
352
+
353
+ except Exception as e:
354
+ logger.error(f"[Token] 记录Token失败信息时发生错误: {str(e)}")
355
+
356
+ async def reset_failure(self, auth_token: str) -> None:
357
+ """重置Token失败计数
358
+
359
+ 当Token成功完成请求时调用此方法,用于清除失败记录。
360
+
361
+ Args:
362
+ auth_token: 完整的认证Token (格式: sso-rw=xxx;sso=xxx)
363
+ """
364
+ try:
365
+ sso_value = self._extract_sso(auth_token)
366
+ if not sso_value:
367
+ return
368
+
369
+ _, token_data = self._find_token(sso_value)
370
+ if not token_data:
371
+ logger.warning(f"[Token] 未找到SSO值为 {sso_value[:10]}... 的Token")
372
+ return
373
+
374
+ # 只有在有失败记录时才重置并保存
375
+ if token_data.get("failedCount", 0) > 0:
376
+ token_data["failedCount"] = 0
377
+ token_data["lastFailureTime"] = None
378
+ token_data["lastFailureReason"] = None
379
+
380
+ await self._save_data()
381
+ logger.info(f"[Token] Token {sso_value[:10]}... 失败计数已重置")
382
+
383
+ except Exception as e:
384
+ logger.error(f"[Token] 重置Token失败计数时发生错误: {str(e)}")
385
+
386
+
387
+ # 全局Token管理器实例
388
+ token_manager = GrokTokenManager()
app/services/grok/upload.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """图片上传管理器"""
2
+
3
+ import base64
4
+ import re
5
+ from typing import Tuple, Optional
6
+ from urllib.parse import urlparse
7
+
8
+ from curl_cffi.requests import AsyncSession
9
+
10
+ from app.services.grok.statsig import get_dynamic_headers
11
+ from app.core.exception import GrokApiException
12
+ from app.core.config import setting
13
+ from app.core.logger import logger
14
+
15
+ # 常量定义
16
+ UPLOAD_ENDPOINT = "https://grok.com/rest/app-chat/upload-file"
17
+ REQUEST_TIMEOUT = 30
18
+ IMPERSONATE_BROWSER = "chrome133a"
19
+ DEFAULT_MIME_TYPE = "image/jpeg"
20
+ DEFAULT_EXTENSION = "jpg"
21
+
22
+
23
+ class ImageUploadManager:
24
+ """
25
+ Grok图片上传管理器
26
+
27
+ 提供图片上传功能,支持:
28
+ - Base64格式图片上传
29
+ - URL图片下载并上传
30
+ - 多种图片格式支持
31
+ """
32
+
33
+ @staticmethod
34
+ async def upload(image_input: str, auth_token: str) -> str:
35
+ """上传图片到Grok,支持Base64或URL"""
36
+ try:
37
+ if ImageUploadManager._is_url(image_input):
38
+ # 下载 URL 图片
39
+ image_buffer, mime_type = await ImageUploadManager._download(image_input)
40
+
41
+ # 获取图片信息
42
+ file_name, _ = ImageUploadManager._get_info("", mime_type)
43
+
44
+ else:
45
+ # 处理 base64 数据
46
+ image_buffer = image_input.split(",")[1] if "data:image" in image_input else image_input
47
+
48
+ # 获取图片信息
49
+ file_name, mime_type = ImageUploadManager._get_info(image_input)
50
+
51
+ # 构建上传数据
52
+ upload_data = {
53
+ "fileName": file_name,
54
+ "fileMimeType": mime_type,
55
+ "content": image_buffer,
56
+ }
57
+
58
+ # 获取认证令牌
59
+ if not auth_token:
60
+ raise GrokApiException("认证令牌缺失或为空", "NO_AUTH_TOKEN")
61
+
62
+ cf_clearance = setting.grok_config.get("cf_clearance", "")
63
+ cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
64
+ proxy_url = setting.grok_config.get("proxy_url", "")
65
+ proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
66
+
67
+ # 发送异步请求
68
+ async with AsyncSession() as session:
69
+ response = await session.post(
70
+ UPLOAD_ENDPOINT,
71
+ headers={
72
+ **get_dynamic_headers("/rest/app-chat/upload-file"),
73
+ "Cookie": cookie,
74
+ },
75
+ json=upload_data,
76
+ impersonate=IMPERSONATE_BROWSER,
77
+ timeout=REQUEST_TIMEOUT,
78
+ proxies=proxies,
79
+ )
80
+
81
+ # 检查响应
82
+ if response.status_code == 200:
83
+ result = response.json()
84
+ file_id = result.get("fileMetadataId", "")
85
+ logger.debug(f"[Upload] 图片上传成功,文件ID: {file_id}")
86
+ return file_id
87
+
88
+ return ""
89
+
90
+ except Exception as e:
91
+ logger.warning(f"[Upload] 上传图片失败: {e}")
92
+ return ""
93
+
94
+ @staticmethod
95
+ def _is_url(image_input: str) -> bool:
96
+ """检查输入是否为有效的URL"""
97
+ try:
98
+ result = urlparse(image_input)
99
+ return all([result.scheme, result.netloc]) and result.scheme in ['http', 'https']
100
+ except Exception as e:
101
+ logger.warning(f"[Upload] URL解析失败: {e}")
102
+ return False
103
+
104
+ @staticmethod
105
+ async def _download(url: str) -> Tuple[str, str]:
106
+ """下载图片并转换为Base64"""
107
+ try:
108
+ async with AsyncSession() as session:
109
+ response = await session.get(url, timeout=5)
110
+ response.raise_for_status()
111
+
112
+ # 获取内容类型
113
+ content_type = response.headers.get('content-type', DEFAULT_MIME_TYPE)
114
+ if not content_type.startswith('image/'):
115
+ content_type = DEFAULT_MIME_TYPE
116
+
117
+ # 转换为 Base64
118
+ image_base64 = base64.b64encode(response.content).decode('utf-8')
119
+ return image_base64, content_type
120
+ except Exception as e:
121
+ logger.warning(f"[Upload] 下载图片失败: {e}")
122
+ return "", ""
123
+
124
+ @staticmethod
125
+ def _get_info(image_data: str, mime_type: Optional[str] = None) -> Tuple[str, str]:
126
+ """获取图片文件名和MIME类型"""
127
+ # mime_type 有值,直接使用
128
+ if mime_type:
129
+ extension = mime_type.split("/")[1] if "/" in mime_type else DEFAULT_EXTENSION
130
+ file_name = f"image.{extension}"
131
+ return file_name, mime_type
132
+
133
+ # mime_type 没有值,使用默认值
134
+ mime_type = DEFAULT_MIME_TYPE
135
+ extension = DEFAULT_EXTENSION
136
+
137
+ # 从 Base64 数据中提取 MIME 类型
138
+ if "data:image" in image_data:
139
+ match = re.search(r"data:([a-zA-Z0-9]+/[a-zA-Z0-9-.+]+);base64,", image_data)
140
+ if match:
141
+ mime_type = match.group(1)
142
+ extension = mime_type.split("/")[1]
143
+
144
+ file_name = f"image.{extension}"
145
+ return file_name, mime_type
app/template/admin.html ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>管理控制台 - Grok2API</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ @keyframes slide-up {
10
+ from {
11
+ transform: translateY(100%);
12
+ opacity: 0;
13
+ }
14
+ to {
15
+ transform: translateY(0);
16
+ opacity: 1;
17
+ }
18
+ }
19
+ .animate-slide-up {
20
+ animation: slide-up 0.3s ease-out;
21
+ }
22
+ .tab-btn {
23
+ transition: all 0.2s ease;
24
+ }
25
+ [title] {
26
+ position: relative;
27
+ }
28
+ [title]:hover::after {
29
+ content: attr(title);
30
+ position: absolute;
31
+ bottom: 100%;
32
+ left: 50%;
33
+ transform: translateX(-50%) translateY(-4px);
34
+ background: hsl(0 0% 3.9%);
35
+ color: hsl(0 0% 98%);
36
+ padding: 4px 8px;
37
+ border-radius: 4px;
38
+ font-size: 11px;
39
+ white-space: nowrap;
40
+ z-index: 1000;
41
+ pointer-events: none;
42
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
43
+ }
44
+ [title]:hover::before {
45
+ content: '';
46
+ position: absolute;
47
+ bottom: 100%;
48
+ left: 50%;
49
+ transform: translateX(-50%);
50
+ border: 4px solid transparent;
51
+ border-top-color: hsl(0 0% 3.9%);
52
+ z-index: 1000;
53
+ }
54
+ </style>
55
+ <script>
56
+ tailwind.config = {
57
+ theme: {
58
+ extend: {
59
+ colors: {
60
+ border: "hsl(0 0% 89%)",
61
+ input: "hsl(0 0% 89%)",
62
+ ring: "hsl(0 0% 3.9%)",
63
+ background: "hsl(0 0% 100%)",
64
+ foreground: "hsl(0 0% 3.9%)",
65
+ primary: {
66
+ DEFAULT: "hsl(0 0% 9%)",
67
+ foreground: "hsl(0 0% 98%)",
68
+ },
69
+ secondary: {
70
+ DEFAULT: "hsl(0 0% 96.1%)",
71
+ foreground: "hsl(0 0% 9%)",
72
+ },
73
+ muted: {
74
+ DEFAULT: "hsl(0 0% 96.1%)",
75
+ foreground: "hsl(0 0% 45.1%)",
76
+ },
77
+ accent: {
78
+ DEFAULT: "hsl(0 0% 96.1%)",
79
+ foreground: "hsl(0 0% 9%)",
80
+ },
81
+ destructive: {
82
+ DEFAULT: "hsl(0 84.2% 60.2%)",
83
+ foreground: "hsl(0 0% 98%)",
84
+ },
85
+ },
86
+ }
87
+ }
88
+ }
89
+ </script>
90
+ </head>
91
+ <body class="h-full bg-background text-foreground antialiased">
92
+ <!-- 导航栏 -->
93
+ <header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
94
+ <div class="mx-auto flex h-14 max-w-7xl items-center px-6">
95
+ <div class="mr-4 flex">
96
+ <span class="font-bold text-lg">Grok2API</span>
97
+ </div>
98
+ <div class="flex flex-1 items-center justify-between space-x-2 md:justify-end">
99
+ <nav class="flex items-center space-x-2">
100
+ <button
101
+ onclick="logout()"
102
+ class="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2"
103
+ >
104
+ 退出
105
+ </button>
106
+ </nav>
107
+ </div>
108
+ </div>
109
+ </header>
110
+
111
+ <main class="mx-auto max-w-7xl px-6 py-6">
112
+ <!-- Tab 导航 -->
113
+ <div class="border-b border-border mb-6">
114
+ <nav class="flex space-x-8">
115
+ <button onclick="switchTab('tokens')" id="tabTokens" class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token 管理</button>
116
+ <button onclick="switchTab('settings')" id="tabSettings" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Setting 配置</button>
117
+ </nav>
118
+ </div>
119
+
120
+ <!-- Token 管理面板 -->
121
+ <div id="panelTokens">
122
+ <!-- 统计卡片 -->
123
+ <div class="grid gap-4 grid-cols-2 md:grid-cols-4 mb-6">
124
+ <div class="rounded-lg border border-border bg-background p-4">
125
+ <p class="text-sm font-medium text-muted-foreground mb-2">Token 总数</p>
126
+ <h3 class="text-xl font-bold" id="statTotal">-</h3>
127
+ </div>
128
+
129
+ <div class="rounded-lg border border-border bg-background p-4">
130
+ <p class="text-sm font-medium text-muted-foreground mb-2">未使用</p>
131
+ <h3 class="text-xl font-bold text-gray-500" id="statUnused">-</h3>
132
+ </div>
133
+
134
+ <div class="rounded-lg border border-border bg-background p-4">
135
+ <p class="text-sm font-medium text-muted-foreground mb-2">限流中</p>
136
+ <h3 class="text-xl font-bold text-orange-600" id="statLimited">-</h3>
137
+ </div>
138
+
139
+ <div class="rounded-lg border border-border bg-background p-4">
140
+ <p class="text-sm font-medium text-muted-foreground mb-2">失效</p>
141
+ <h3 class="text-xl font-bold text-destructive" id="statExpired">-</h3>
142
+ </div>
143
+
144
+ <div class="rounded-lg border border-border bg-background p-4">
145
+ <p class="text-sm font-medium text-muted-foreground mb-2">正常</p>
146
+ <h3 class="text-xl font-bold text-green-600" id="statActive">-</h3>
147
+ </div>
148
+
149
+ <div class="rounded-lg border border-border bg-background p-4">
150
+ <p class="text-sm font-medium text-muted-foreground mb-2">总剩余</p>
151
+ <h3 class="text-xl font-bold" id="statTotalRemaining">-</h3>
152
+ </div>
153
+
154
+ <div class="rounded-lg border border-border bg-background p-4">
155
+ <p class="text-sm font-medium text-muted-foreground mb-2">普通剩余</p>
156
+ <h3 class="text-xl font-bold text-blue-600" id="statNormalRemaining">-</h3>
157
+ </div>
158
+
159
+ <div class="rounded-lg border border-border bg-background p-4">
160
+ <p class="text-sm font-medium text-muted-foreground mb-2">高级剩余</p>
161
+ <h3 class="text-xl font-bold text-purple-600" id="statHeavyRemaining">-</h3>
162
+ </div>
163
+ </div>
164
+
165
+ <!-- Token 列表 -->
166
+ <div class="rounded-lg border border-border bg-background">
167
+ <!-- 工具栏 -->
168
+ <div class="flex items-center justify-between gap-4 p-4 border-b border-border">
169
+ <div class="flex items-center gap-3 flex-1">
170
+ <div class="flex items-center gap-2">
171
+ <select id="filterType" onchange="filterTokens()" class="h-8 px-3 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring">
172
+ <option value="all">全部类型</option>
173
+ <option value="sso">SSO</option>
174
+ <option value="ssoSuper">SuperSSO</option>
175
+ </select>
176
+ <select id="filterStatus" onchange="filterTokens()" class="h-8 px-3 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring">
177
+ <option value="all">全部状态</option>
178
+ <option value="未使用">未使用</option>
179
+ <option value="限流中">限流中</option>
180
+ <option value="失效">失效</option>
181
+ <option value="正常">正常</option>
182
+ </select>
183
+ </div>
184
+ </div>
185
+
186
+ <div class="flex items-center gap-2">
187
+ <button
188
+ onclick="refreshTokens()"
189
+ class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground h-8 w-8"
190
+ title="刷新列表"
191
+ >
192
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
193
+ <polyline points="23 4 23 10 17 10"/>
194
+ <polyline points="1 20 1 14 7 14"/>
195
+ <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"/>
196
+ </svg>
197
+ </button>
198
+ <div id="batchActions" class="hidden items-center gap-2">
199
+ <button
200
+ onclick="exportSelected()"
201
+ class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground h-8 w-8"
202
+ title="导出选中项"
203
+ >
204
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
205
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
206
+ <polyline points="7 10 12 15 17 10"/>
207
+ <line x1="12" y1="15" x2="12" y2="3"/>
208
+ </svg>
209
+ </button>
210
+ <button
211
+ onclick="batchDelete()"
212
+ class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-8 w-8"
213
+ title="批量删除"
214
+ >
215
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
216
+ <polyline points="3 6 5 6 21 6"/>
217
+ <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"/>
218
+ <line x1="10" y1="11" x2="10" y2="17"/>
219
+ <line x1="14" y1="11" x2="14" y2="17"/>
220
+ </svg>
221
+ </button>
222
+ </div>
223
+ <button
224
+ onclick="openAddModal()"
225
+ class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3"
226
+ title="添加 Token"
227
+ >
228
+ <svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
229
+ <line x1="12" y1="5" x2="12" y2="19"/>
230
+ <line x1="5" y1="12" x2="19" y2="12"/>
231
+ </svg>
232
+ <span class="text-sm font-medium">新增</span>
233
+ </button>
234
+ </div>
235
+ </div>
236
+
237
+ <!-- 表格 -->
238
+ <div class="relative w-full overflow-auto">
239
+ <table class="w-full text-sm table-fixed">
240
+ <thead>
241
+ <tr class="border-b border-border">
242
+ <th class="h-10 px-3 text-left align-middle font-medium w-12">
243
+ <input
244
+ type="checkbox"
245
+ id="selectAll"
246
+ onchange="toggleSelectAll()"
247
+ class="h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring"
248
+ >
249
+ </th>
250
+ <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-72">Token</th>
251
+ <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">类型</th>
252
+ <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">状态</th>
253
+ <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">普通剩余</th>
254
+ <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">高级剩余</th>
255
+ <th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-40">创建时间</th>
256
+ <th class="h-10 px-3 text-right align-middle text-sm font-medium text-muted-foreground w-20">操作</th>
257
+ </tr>
258
+ </thead>
259
+ <tbody id="tokenTableBody" class="divide-y divide-border">
260
+ <!-- 动态填充 -->
261
+ </tbody>
262
+ </table>
263
+ </div>
264
+
265
+ <div id="emptyState" class="hidden flex flex-col items-center justify-center py-12">
266
+ <svg class="h-10 w-10 text-muted-foreground/50 mb-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
267
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
268
+ <path d="M9 9h6v6H9z"/>
269
+ </svg>
270
+ <p class="text-sm text-muted-foreground">暂无数据</p>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ <!-- 全局设置面板 -->
276
+ <div id="panelSettings" class="hidden">
277
+ <div class="grid gap-6 lg:grid-cols-2">
278
+ <!-- 全局配置 -->
279
+ <div class="rounded-lg border border-border bg-background p-6">
280
+ <h3 class="text-lg font-semibold mb-4">全局配置</h3>
281
+ <div class="space-y-4">
282
+ <div>
283
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
284
+ 管理账户
285
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="登录管理后台的用户名">?</span>
286
+ </label>
287
+ <input id="cfgAdminUser" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="admin">
288
+ </div>
289
+ <div>
290
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
291
+ 管理密码
292
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="登录管理后台的密码,留空表示不修改当前密码">?</span>
293
+ </label>
294
+ <input id="cfgAdminPass" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="留空则不修改">
295
+ </div>
296
+ <div>
297
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
298
+ 日志级别
299
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="日志详细程度。DEBUG:最详细 | INFO:一般信息 | WARNING:警告 | ERROR:仅错误">?</span>
300
+ </label>
301
+ <select id="cfgLogLevel" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
302
+ <option>DEBUG</option><option>INFO</option><option>WARNING</option><option>ERROR</option>
303
+ </select>
304
+ </div>
305
+ <div>
306
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
307
+ 缓存上限 (MB)
308
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="图片缓存目录的最大容量,超过后自动删除最旧的缓存文件">?</span>
309
+ </label>
310
+ <input id="cfgTempMaxSize" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="500">
311
+ </div>
312
+ <div>
313
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
314
+ 缓存大小
315
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="当前缓存目录的大小">?</span>
316
+ </label>
317
+ <div class="flex gap-2">
318
+ <input id="cacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="0 MB">
319
+ <button onclick="clearCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-orange-600 text-white hover:bg-orange-700 h-9 px-3 transition-colors">
320
+ <svg class="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
321
+ <path d="M3 6h18"/>
322
+ <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"/>
323
+ <line x1="10" y1="11" x2="10" y2="17"/>
324
+ <line x1="14" y1="11" x2="14" y2="17"/>
325
+ </svg>
326
+ 清除
327
+ </button>
328
+ </div>
329
+ </div>
330
+ <div>
331
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
332
+ 服务网址
333
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="用于生成图片链接的服务地址。建议使用域名以保护服务器IP,如无图片需求可留空">?</span>
334
+ </label>
335
+ <input id="cfgBaseUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://localhost:8000">
336
+ </div>
337
+ </div>
338
+ <div class="mt-6 flex justify-end">
339
+ <button onclick="saveGlobalSettings()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-6 transition-colors">保存配置</button>
340
+ </div>
341
+ </div>
342
+
343
+ <!-- Grok 配置 -->
344
+ <div class="rounded-lg border border-border bg-background p-6">
345
+ <h3 class="text-lg font-semibold mb-4">Grok 配置</h3>
346
+ <div class="space-y-4">
347
+ <div>
348
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
349
+ API Key
350
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="API访问密钥。建议填写以提高安全性,可留空">?</span>
351
+ </label>
352
+ <input id="cfgApiKey" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
353
+ </div>
354
+ <div>
355
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
356
+ Proxy Url
357
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="HTTP代理服务器地址,用于访问Grok服务,可留空">?</span>
358
+ </label>
359
+ <input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890">
360
+ </div>
361
+ <div>
362
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
363
+ CF Clearance
364
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="Cloudflare安全令牌,用于绕过人机验证,可留空">?</span>
365
+ </label>
366
+ <input id="cfgCfClearance" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
367
+ </div>
368
+ <div>
369
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
370
+ X Statsig ID
371
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="Grok反机器人检测的唯一标识符,必填">?</span>
372
+ </label>
373
+ <input id="cfgStatsigId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
374
+ </div>
375
+ <div>
376
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
377
+ Filtered_tags
378
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="过滤Grok响应中的指定标签,多个标签用逗号分隔">?</span>
379
+ </label>
380
+ <input id="cfgFilteredTags" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="xaiartifact,xai:tool_usage_card">
381
+ </div>
382
+ <div>
383
+ <label class="text-sm font-medium flex items-center gap-1 mb-2">
384
+ 临时会话
385
+ <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="选择会话模式:标准模式保留上下文,临时模式每次对话独立">?</span>
386
+ </label>
387
+ <select id="cfgTemporary" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
388
+ <option value="false">关闭</option>
389
+ <option value="true">开启</option>
390
+ </select>
391
+ </div>
392
+ </div>
393
+ <div class="mt-6 flex justify-end">
394
+ <button onclick="saveGrokSettings()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-6 transition-colors">保存配置</button>
395
+ </div>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </main>
400
+
401
+ <!-- 添加 Token 模态框 -->
402
+ <div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
403
+ <div class="bg-background rounded-lg border border-border w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
404
+ <div class="flex items-center justify-between p-5 border-b border-border">
405
+ <h3 class="text-lg font-semibold">添加 Token</h3>
406
+ <button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground transition-colors">
407
+ <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
408
+ <line x1="18" y1="6" x2="6" y2="18"/>
409
+ <line x1="6" y1="6" x2="18" y2="18"/>
410
+ </svg>
411
+ </button>
412
+ </div>
413
+ <div class="p-5 space-y-4">
414
+ <div class="space-y-2">
415
+ <label class="text-sm font-medium">Token 类型</label>
416
+ <select id="addTokenType" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
417
+ <option value="sso">SSO</option>
418
+ <option value="ssoSuper">SuperSSO</option>
419
+ </select>
420
+ </div>
421
+ <div class="space-y-2">
422
+ <label class="text-sm font-medium">Token 列表 <span class="text-muted-foreground">(每行一个)</span></label>
423
+ <textarea
424
+ id="addTokenList"
425
+ rows="12"
426
+ placeholder="请输入 Token,每行一个"
427
+ class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring font-mono resize-none"
428
+ ></textarea>
429
+ </div>
430
+ </div>
431
+ <div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
432
+ <button
433
+ onclick="closeAddModal()"
434
+ class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent h-9 px-5"
435
+ >
436
+ 取消
437
+ </button>
438
+ <button
439
+ onclick="submitAddTokens()"
440
+ class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"
441
+ >
442
+ 添加
443
+ </button>
444
+ </div>
445
+ </div>
446
+ </div>
447
+
448
+ <script>
449
+ let allTokens=[],filteredTokens=[],selectedTokens=new Set();
450
+ const $=(id)=>document.getElementById(id),
451
+ checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
452
+ apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
453
+ loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();if(d.success){const s=d.data;$('statTotal').textContent=s.total||0;['Unused','Limited','Expired','Active'].forEach((k,i)=>$(`stat${k}`).textContent=(s.normal?.[k.toLowerCase()]||0)+(s.super?.[k.toLowerCase()]||0))}}catch(e){console.error('加载统计失败:',e)}},
454
+ calcRemaining=()=>{let n=0,h=0;allTokens.forEach(t=>{if(t.remaining_queries>0)n+=t.remaining_queries;if(t.heavy_remaining_queries>0)h+=t.heavy_remaining_queries});return{normal:n,heavy:h,total:n+h}},
455
+ loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;const d=await r.json();d.success&&(allTokens=d.data,filteredTokens=allTokens,selectedTokens.clear(),renderTokens(),updateRemaining())}catch(e){console.error('加载列表失败:',e)}},
456
+ updateRemaining=()=>{const r=calcRemaining();['Total','Normal','Heavy'].forEach(k=>$(`stat${k}Remaining`).textContent=r[k.toLowerCase()]===0?'-':r[k.toLowerCase()].toLocaleString())}
457
+
458
+ const renderTokens=()=>{const tb=$('tokenTableBody'),es=$('emptyState'),ss={'未使用':'bg-muted text-muted-foreground','限流中':'bg-orange-50 text-orange-700 border-orange-200','失效':'bg-destructive/10 text-destructive border-destructive/20','正常':'bg-green-50 text-green-700 border-green-200'},ts={sso:'bg-blue-50 text-blue-700 border-blue-200',ssoSuper:'bg-purple-50 text-purple-700 border-purple-200'},tl={sso:'SSO',ssoSuper:'SuperSSO'};if(!filteredTokens.length){tb.innerHTML='';es.classList.remove('hidden');$('selectAll').checked=false;return updateBatchActions()}es.classList.add('hidden');tb.innerHTML=filteredTokens.map(t=>`
459
+ <tr class="transition-colors">
460
+ <td class="py-2.5 px-3 align-middle w-12"><input type="checkbox" class="token-checkbox h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring" data-token="${t.token}" data-type="${t.token_type}" ${selectedTokens.has(t.token)?'checked':''} onchange="toggleToken('${t.token}')"></td>
461
+ <td class="py-2.5 px-3 align-middle w-80"><div class="flex items-center gap-2"><span class="font-mono text-xs">${t.token.substring(0,30)}...</span><button onclick="copyToken('${t.token.replace(/'/g,"\\'")}',event)" class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent h-6 w-6" title="复制完整 Token"><svg class="h-3 w-3 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div></td>
462
+ <td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ts[t.token_type]}">${tl[t.token_type]}</span></td>
463
+ <td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ss[t.status]}">${t.status}</span></td>
464
+ <td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries===-1?'-':t.remaining_queries}</td>
465
+ <td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.heavy_remaining_queries===-1?'-':t.heavy_remaining_queries}</td>
466
+ <td class="py-2.5 px-3 align-middle w-32 text-xs text-muted-foreground">${t.created_time?new Date(t.created_time).toLocaleString('zh-CN',{dateStyle:'short',timeStyle:'short'}):'-'}</td>
467
+ <td class="py-2.5 px-3 align-middle text-right w-16"><button onclick="deleteToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-7 w-7"><svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/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></button></td>
468
+ </tr>`).join('');updateBatchActions()},
469
+ toggleToken=t=>selectedTokens[selectedTokens.has(t)?'delete':'add'](t)||updateBatchActions(),
470
+ toggleSelectAll=()=>{const sa=$('selectAll');sa.checked?filteredTokens.forEach(t=>selectedTokens.add(t.token)):selectedTokens.clear();renderTokens()},
471
+ updateBatchActions=()=>{const ba=$('batchActions'),sc=$('selectedCount'),c=selectedTokens.size;ba.classList[c>0?'add':'remove']('flex');ba.classList[c>0?'remove':'add']('hidden');c>0&&(sc.textContent=`已选择 ${c} 项`);$('selectAll').checked=filteredTokens.length>0&&c===filteredTokens.length},
472
+ filterTokens=()=>{const tf=$('filterType').value,sf=$('filterStatus').value;filteredTokens=allTokens.filter(t=>(tf==='all'||t.token_type===tf)&&(sf==='all'||t.status===sf));selectedTokens.clear();renderTokens()},
473
+ refreshTokens=async()=>{await loadTokens();await loadStats()},
474
+ openAddModal=()=>$('addModal').classList.remove('hidden'),
475
+ closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenList').value=''},
476
+ deleteToken=async(t,tt)=>{if(!confirm('确定要删除这个 Token 吗?'))return;try{const r=await apiRequest('/api/tokens/delete',{method:'POST',body:JSON.stringify({tokens:[t],token_type:tt})});if(!r)return;const d=await r.json();d.success?await refreshTokens():alert('删除失败: '+(d.error||'未知错误'))}catch(e){alert('删除失败: '+e.message)}},
477
+ batchDelete=async()=>{if(!selectedTokens.size||!confirm(`确定要删除选中的 ${selectedTokens.size} 个 Token 吗?此操作不可恢复!`))return;const tbt={sso:[],ssoSuper:[]};document.querySelectorAll('.token-checkbox:checked').forEach(cb=>tbt[cb.dataset.type].push(cb.dataset.token));try{const ps=[];['sso','ssoSuper'].forEach(k=>tbt[k].length&&ps.push(apiRequest('/api/tokens/delete',{method:'POST',body:JSON.stringify({tokens:tbt[k],token_type:k})})));await Promise.all(ps);await refreshTokens()}catch(e){alert('批量删除失败: '+e.message)}},
478
+ submitAddTokens=async()=>{const tt=$('addTokenType').value,tks=$('addTokenList').value.split('\n').map(t=>t.trim()).filter(t=>t);if(!tks.length)return alert('请输入至少一个 Token');try{const r=await apiRequest('/api/tokens/add',{method:'POST',body:JSON.stringify({tokens:tks,token_type:tt})});if(!r)return;const d=await r.json();d.success?(closeAddModal(),await refreshTokens()):alert('添加失败: '+(d.error||'未知错误'))}catch(e){alert('添加失败: '+e.message)}},
479
+ copyToken=async(t,e)=>{e.stopPropagation();try{await navigator.clipboard.writeText(t);showToast('Token 已复制到剪贴板','success')}catch(err){console.error('复制失败:',err);showToast('复制失败,请手动复制','error')}}
480
+
481
+ const exportSelected=()=>{if(!selectedTokens.size)return showToast('请先选择要导出的 Token','error');const sd=allTokens.filter(t=>selectedTokens.has(t.token)),csv=[['Token','类型','状态','普通调用剩余','高级调用剩余','创建时间'].join(','),...sd.map(t=>[`"${t.token}"`,t.token_type==='sso'?'SSO':'SuperSSO',t.status,t.remaining_queries===-1?'未使用':t.remaining_queries,t.heavy_remaining_queries===-1?'未使用':t.heavy_remaining_queries,`"${t.created_time?new Date(t.created_time).toLocaleString('zh-CN'):'-'}"`].join(','))].join('\n'),l=document.createElement('a');l.href=URL.createObjectURL(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8;'}));l.download=`grok_tokens_${new Date().toISOString().slice(0,10)}.csv`;l.style.visibility='hidden';document.body.appendChild(l);l.click();document.body.removeChild(l);URL.revokeObjectURL(l.href);showToast(`已导出 ${selectedTokens.size} 个 Token`,'success')},
482
+ showToast=(m,t='info')=>{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.info} 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 0.3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
483
+ logout=async()=>{if(!confirm('确定要退出登录吗?'))return;try{await apiRequest('/api/logout',{method:'POST'})}catch(e){console.error('登出失败:',e)}finally{localStorage.removeItem('adminToken');location.href='/login'}},
484
+ switchTab=t=>{['tokens','settings'].forEach(n=>{$(`panel${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'remove':'add']('hidden');$(`tab${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'add':'remove']('border-primary','text-primary');$(`tab${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'remove':'add']('border-transparent','text-muted-foreground')});t==='settings'&&loadSettings()},
485
+ loadSettings=async()=>{try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(d.success){const g=d.data.global,k=d.data.grok;$('cfgAdminUser').value=g.admin_username||'';$('cfgAdminPass').value='';$('cfgLogLevel').value=g.log_level||'DEBUG';$('cfgTempMaxSize').value=g.temp_max_size_mb||500;$('cfgBaseUrl').value=g.base_url||'';$('cfgApiKey').value=k.api_key||'';$('cfgProxyUrl').value=k.proxy_url||'';$('cfgCfClearance').value=k.cf_clearance||'';$('cfgStatsigId').value=k.x_statsig_id||'';$('cfgFilteredTags').value=k.filtered_tags||'';$('cfgTemporary').value=k.temporary!==false?'true':'false';await loadCacheSize()}}catch(e){console.error('加载配置失败:',e);showToast('加载配置失败','error')}},
486
+ loadCacheSize=async()=>{try{const r=await apiRequest('/api/cache/size');if(!r)return;const d=await r.json();if(d.success){$('cacheSize').value=d.data.size||'0 MB'}}catch(e){console.error('加载缓存大小失败:',e);$('cacheSize').value='0 MB'}},
487
+ clearCache=async()=>{if(!confirm('确定要清理缓存吗?此操作将删除 /data/temp 目录中的所有文件!'))return;try{const r=await apiRequest('/api/cache/clear',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}},
488
+ saveGlobalSettings=async()=>{const gc={admin_username:$('cfgAdminUser').value,log_level:$('cfgLogLevel').value,temp_max_size_mb:parseInt($('cfgTempMaxSize').value)||500,base_url:$('cfgBaseUrl').value};if($('cfgAdminPass').value)gc.admin_password=$('cfgAdminPass').value;try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(!d.success)return showToast('加载配置失败','error');const s=await apiRequest('/api/settings',{method:'POST',body:JSON.stringify({global_config:gc,grok_config:d.data.grok})});if(!s)return;const sd=await s.json();sd.success?(showToast('全局配置保存成功','success'),$('cfgAdminPass').value=''):showToast('保存失败: '+(sd.error||'未知错误'),'error')}catch(e){showToast('保���失败: '+e.message,'error')}},
489
+ saveGrokSettings=async()=>{const kc={api_key:$('cfgApiKey').value,proxy_url:$('cfgProxyUrl').value,cf_clearance:$('cfgCfClearance').value,x_statsig_id:$('cfgStatsigId').value,filtered_tags:$('cfgFilteredTags').value,temporary:$('cfgTemporary').value==='true'};try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(!d.success)return showToast('加载配置失败','error');const s=await apiRequest('/api/settings',{method:'POST',body:JSON.stringify({global_config:d.data.global,grok_config:kc})});if(!s)return;const sd=await s.json();sd.success?showToast('Grok配置保存成功','success'):showToast('保存失败: '+(sd.error||'未知错误'),'error')}catch(e){showToast('保存失败: '+e.message,'error')}};
490
+ window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();setInterval(()=>{loadStats();updateRemaining()},30000)});
491
+ </script>
492
+ </body>
493
+ </html>
app/template/login.html ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>登录 - Grok2API</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ theme: {
11
+ extend: {
12
+ colors: {
13
+ border: "hsl(0 0% 89%)",
14
+ input: "hsl(0 0% 89%)",
15
+ ring: "hsl(0 0% 3.9%)",
16
+ background: "hsl(0 0% 100%)",
17
+ foreground: "hsl(0 0% 3.9%)",
18
+ primary: {
19
+ DEFAULT: "hsl(0 0% 9%)",
20
+ foreground: "hsl(0 0% 98%)",
21
+ },
22
+ secondary: {
23
+ DEFAULT: "hsl(0 0% 96.1%)",
24
+ foreground: "hsl(0 0% 9%)",
25
+ },
26
+ muted: {
27
+ DEFAULT: "hsl(0 0% 96.1%)",
28
+ foreground: "hsl(0 0% 45.1%)",
29
+ },
30
+ destructive: {
31
+ DEFAULT: "hsl(0 84.2% 60.2%)",
32
+ foreground: "hsl(0 0% 98%)",
33
+ },
34
+ },
35
+ }
36
+ }
37
+ }
38
+ </script>
39
+ <style>
40
+ @keyframes slide-up {
41
+ from {
42
+ transform: translateY(100%);
43
+ opacity: 0;
44
+ }
45
+ to {
46
+ transform: translateY(0);
47
+ opacity: 1;
48
+ }
49
+ }
50
+ .animate-slide-up {
51
+ animation: slide-up 0.3s ease-out;
52
+ }
53
+ </style>
54
+ </head>
55
+ <body class="h-full bg-background text-foreground antialiased">
56
+ <div class="flex min-h-full flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
57
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
58
+ <div class="text-center">
59
+ <h1 class="text-4xl font-bold">Grok2API</h1>
60
+ <p class="mt-2 text-sm text-muted-foreground">管理员控制台</p>
61
+ </div>
62
+ </div>
63
+
64
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
65
+ <div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
66
+ <form id="loginForm" class="space-y-6">
67
+ <!-- 账户 -->
68
+ <div class="space-y-2">
69
+ <label for="username" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
70
+ 账户
71
+ </label>
72
+ <input
73
+ type="text"
74
+ id="username"
75
+ name="username"
76
+ required
77
+ class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
78
+ placeholder="请输入用户名"
79
+ >
80
+ </div>
81
+
82
+ <!-- 密码 -->
83
+ <div class="space-y-2">
84
+ <label for="password" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
85
+ 密码
86
+ </label>
87
+ <input
88
+ type="password"
89
+ id="password"
90
+ name="password"
91
+ required
92
+ class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
93
+ placeholder="请输入密码"
94
+ >
95
+ </div>
96
+
97
+ <!-- 登录按钮 -->
98
+ <button
99
+ type="submit"
100
+ id="loginButton"
101
+ class="inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full text-sm"
102
+ >
103
+ ���录
104
+ </button>
105
+ </form>
106
+
107
+ <div class="mt-6 text-center text-xs text-muted-foreground">
108
+ <p>Created By Chenyme © 2025</p>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <script>
115
+ const loginForm = document.getElementById('loginForm');
116
+ const loginButton = document.getElementById('loginButton');
117
+
118
+ loginForm.addEventListener('submit', async (e) => {
119
+ e.preventDefault();
120
+
121
+ loginButton.disabled = true;
122
+ loginButton.textContent = '登录中...';
123
+
124
+ try {
125
+ const formData = new FormData(loginForm);
126
+ const response = await fetch('/api/login', {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify({
130
+ username: formData.get('username'),
131
+ password: formData.get('password')
132
+ })
133
+ });
134
+
135
+ const data = await response.json();
136
+
137
+ if (data.success) {
138
+ localStorage.setItem('adminToken', data.token);
139
+ window.location.href = '/manage';
140
+ } else {
141
+ showToast(data.message || '登录失败', 'error');
142
+ }
143
+ } catch (error) {
144
+ showToast('网络错误,请稍后重试', 'error');
145
+ } finally {
146
+ loginButton.disabled = false;
147
+ loginButton.textContent = '登录';
148
+ }
149
+ });
150
+
151
+ function showToast(message, type = 'error') {
152
+ const toast = document.createElement('div');
153
+ const bgColors = {
154
+ success: 'bg-green-600',
155
+ error: 'bg-destructive',
156
+ info: 'bg-primary'
157
+ };
158
+ toast.className = `fixed bottom-4 right-4 ${bgColors[type] || bgColors.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;
159
+ toast.textContent = message;
160
+ document.body.appendChild(toast);
161
+ setTimeout(() => {
162
+ toast.style.opacity = '0';
163
+ toast.style.transition = 'opacity 0.3s';
164
+ setTimeout(() => toast.parentNode && document.body.removeChild(toast), 300);
165
+ }, 2000);
166
+ }
167
+
168
+ window.addEventListener('DOMContentLoaded', () => {
169
+ const token = localStorage.getItem('adminToken');
170
+ if (token) {
171
+ fetch('/api/stats', {
172
+ headers: { 'Authorization': `Bearer ${token}` }
173
+ }).then(response => {
174
+ if (response.ok) window.location.href = '/manage';
175
+ });
176
+ }
177
+ });
178
+ </script>
179
+ </body>
180
+ </html>
data/setting.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [global]
2
+ admin_username = "admin"
3
+ admin_password = "admin"
4
+ log_level = "INFO"
5
+ temp_max_size_mb = 500
6
+ base_url = ""
7
+
8
+ [grok]
9
+ api_key = ""
10
+ proxy_url = ""
11
+ cf_clearance = ""
12
+ temporary = true
13
+ filtered_tags = "xaiartifact,xai:tool_usage_card,grok:render"
14
+ x_statsig_id = "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
data/temp/image.temp ADDED
File without changes
data/token.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "ssoNormal": {},
3
+ "ssoSuper": {}
4
+ }
docker-compose.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ grok2api:
3
+ image: ghcr.io/chenyme/grok2api:latest
4
+ ports:
5
+ - "8000:8000"
6
+ volumes:
7
+ - grok_data:/app/data
8
+ - ./logs:/app/logs
9
+
10
+ volumes:
11
+ grok_data:
main.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI应用主入口"""
2
+
3
+ from contextlib import asynccontextmanager
4
+ from fastapi import FastAPI
5
+ from fastapi.staticfiles import StaticFiles
6
+ from app.core.logger import logger
7
+ from app.core.exception import register_exception_handlers
8
+ from app.api.v1.chat import router as chat_router
9
+ from app.api.v1.models import router as models_router
10
+ from app.api.v1.images import router as images_router
11
+ from app.api.admin.manage import router as admin_router
12
+
13
+
14
+ @asynccontextmanager
15
+ async def lifespan(app: FastAPI):
16
+ """应用生命周期管理"""
17
+ logger.debug("[Web2API] 应用启动成功")
18
+ yield
19
+ logger.info("[Web2API] 应用关闭成功")
20
+
21
+
22
+ # 初始化日志
23
+ logger.info("[Web2API] 应用正在启动...")
24
+
25
+ # 创建FastAPI应用
26
+ app = FastAPI(
27
+ title="Web2API",
28
+ description="Web服务API",
29
+ version="1.0.0",
30
+ lifespan=lifespan
31
+ )
32
+
33
+ # 注册全局异常处理器
34
+ register_exception_handlers(app)
35
+
36
+ # 注册路由
37
+ app.include_router(chat_router, prefix="/v1")
38
+ app.include_router(models_router, prefix="/v1")
39
+ app.include_router(images_router)
40
+ app.include_router(admin_router)
41
+
42
+ # 挂载静态文件(注意:这个应该在API路由之后,避免拦截API请求)
43
+ app.mount("/static", StaticFiles(directory="app/template"), name="template")
44
+
45
+ @app.get("/")
46
+ async def root():
47
+ """根路径"""
48
+ return {"message": "Welcome to Web2API"}
49
+
50
+
51
+ if __name__ == "__main__":
52
+ import uvicorn
53
+ uvicorn.run(app, host="0.0.0.0", port=8001)
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ toml==0.10.2
2
+ fastapi==0.118.2
3
+ uvicorn==0.37.0
4
+ python-dotenv==1.1.1
5
+ curl_cffi==0.13.0
6
+ requests==2.32.5
7
+ starlette==0.48.0
8
+ pydantic==2.12.0
9
+ aiofiles==24.1.0