Skip to content

Commit 92a6f95

Browse files
committed
feat: api key quotas and web chat UI
1 parent 746bd8f commit 92a6f95

21 files changed

Lines changed: 2660 additions & 31 deletions

File tree

app/api/v1/admin.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Body
22
from fastapi.responses import HTMLResponse, RedirectResponse
33
from pydantic import BaseModel
4+
from typing import Any
45

56
from app.core.auth import verify_api_key
67
from app.core.config import config, get_config
@@ -12,6 +13,7 @@
1213
import json
1314
from app.core.logger import logger
1415
from app.services.register import get_auto_register_manager
16+
from app.services.api_keys import api_key_manager
1517

1618

1719
router = APIRouter()
@@ -65,6 +67,21 @@ async def admin_datacenter_page():
6567
"""数据中心页"""
6668
return await render_template("datacenter/datacenter.html")
6769

70+
@router.get("/admin/keys", response_class=HTMLResponse, include_in_schema=False)
71+
async def admin_keys_page():
72+
"""API Key 管理页"""
73+
return await render_template("keys/keys.html")
74+
75+
@router.get("/chat", response_class=HTMLResponse, include_in_schema=False)
76+
async def chat_page():
77+
"""在线聊天页(公开入口)"""
78+
return await render_template("chat/chat.html")
79+
80+
@router.get("/admin/chat", response_class=HTMLResponse, include_in_schema=False)
81+
async def admin_chat_page():
82+
"""在线聊天页(后台入口)"""
83+
return await render_template("chat/chat_admin.html")
84+
6885
@router.post("/api/v1/admin/login")
6986
async def admin_login_api(request: Request, body: AdminLoginBody | None = Body(default=None)):
7087
"""管理后台登录验证(用户名+密码)
@@ -110,6 +127,143 @@ async def update_config_api(data: dict):
110127
except Exception as e:
111128
raise HTTPException(status_code=500, detail=str(e))
112129

130+
131+
def _display_key(key: str) -> str:
132+
k = str(key or "")
133+
if len(k) <= 12:
134+
return k
135+
return f"{k[:6]}...{k[-4:]}"
136+
137+
138+
def _normalize_limit(v: Any) -> int:
139+
if v is None or v == "":
140+
return -1
141+
try:
142+
return max(-1, int(v))
143+
except Exception:
144+
return -1
145+
146+
147+
@router.get("/api/v1/admin/keys", dependencies=[Depends(verify_api_key)])
148+
async def list_api_keys():
149+
"""List API keys + daily usage/remaining (for admin UI)."""
150+
await api_key_manager.init()
151+
day, usage_map = await api_key_manager.usage_today()
152+
153+
out = []
154+
for row in api_key_manager.get_all_keys():
155+
key = str(row.get("key") or "")
156+
used = usage_map.get(key) or {}
157+
chat_used = int(used.get("chat_used", 0) or 0)
158+
heavy_used = int(used.get("heavy_used", 0) or 0)
159+
image_used = int(used.get("image_used", 0) or 0)
160+
video_used = int(used.get("video_used", 0) or 0)
161+
162+
chat_limit = _normalize_limit(row.get("chat_limit", -1))
163+
heavy_limit = _normalize_limit(row.get("heavy_limit", -1))
164+
image_limit = _normalize_limit(row.get("image_limit", -1))
165+
video_limit = _normalize_limit(row.get("video_limit", -1))
166+
167+
remaining = {
168+
"chat": None if chat_limit < 0 else max(0, chat_limit - chat_used),
169+
"heavy": None if heavy_limit < 0 else max(0, heavy_limit - heavy_used),
170+
"image": None if image_limit < 0 else max(0, image_limit - image_used),
171+
"video": None if video_limit < 0 else max(0, video_limit - video_used),
172+
}
173+
174+
out.append({
175+
**row,
176+
"is_active": bool(row.get("is_active", True)),
177+
"display_key": _display_key(key),
178+
"usage_today": {
179+
"chat_used": chat_used,
180+
"heavy_used": heavy_used,
181+
"image_used": image_used,
182+
"video_used": video_used,
183+
},
184+
"remaining_today": remaining,
185+
"day": day,
186+
})
187+
188+
# New UI expects { success: true, data: [...] }
189+
return {"success": True, "data": out}
190+
191+
192+
@router.post("/api/v1/admin/keys", dependencies=[Depends(verify_api_key)])
193+
async def create_api_key(data: dict):
194+
"""Create a new API key (optional name/key/limits)."""
195+
await api_key_manager.init()
196+
data = data or {}
197+
198+
name = str(data.get("name") or "").strip() or api_key_manager.generate_name()
199+
key_val = str(data.get("key") or "").strip() or None
200+
is_active = bool(data.get("is_active", True))
201+
202+
limits = data.get("limits") if isinstance(data.get("limits"), dict) else {}
203+
try:
204+
row = await api_key_manager.add_key(
205+
name=name,
206+
key=key_val,
207+
is_active=is_active,
208+
limits={
209+
"chat_per_day": limits.get("chat_per_day"),
210+
"heavy_per_day": limits.get("heavy_per_day"),
211+
"image_per_day": limits.get("image_per_day"),
212+
"video_per_day": limits.get("video_per_day"),
213+
},
214+
)
215+
except ValueError as e:
216+
raise HTTPException(status_code=400, detail=str(e))
217+
218+
return {"success": True, "data": {**row, "display_key": _display_key(row.get("key", ""))}}
219+
220+
221+
@router.post("/api/v1/admin/keys/update", dependencies=[Depends(verify_api_key)])
222+
async def update_api_key(data: dict):
223+
"""Update name/status/limits for an API key."""
224+
await api_key_manager.init()
225+
data = data or {}
226+
key = str(data.get("key") or "").strip()
227+
if not key:
228+
raise HTTPException(status_code=400, detail="Missing key")
229+
230+
if "name" in data and data.get("name") is not None:
231+
name = str(data.get("name") or "").strip()
232+
if name:
233+
await api_key_manager.update_key_name(key, name)
234+
235+
if "is_active" in data:
236+
await api_key_manager.update_key_status(key, bool(data.get("is_active")))
237+
238+
limits = data.get("limits") if isinstance(data.get("limits"), dict) else None
239+
if limits is not None:
240+
await api_key_manager.update_key_limits(
241+
key,
242+
{
243+
"chat_per_day": limits.get("chat_per_day"),
244+
"heavy_per_day": limits.get("heavy_per_day"),
245+
"image_per_day": limits.get("image_per_day"),
246+
"video_per_day": limits.get("video_per_day"),
247+
},
248+
)
249+
250+
return {"success": True}
251+
252+
253+
@router.post("/api/v1/admin/keys/delete", dependencies=[Depends(verify_api_key)])
254+
async def delete_api_key(data: dict):
255+
"""Delete an API key."""
256+
await api_key_manager.init()
257+
data = data or {}
258+
key = str(data.get("key") or "").strip()
259+
if not key:
260+
raise HTTPException(status_code=400, detail="Missing key")
261+
262+
ok = await api_key_manager.delete_key(key)
263+
if not ok:
264+
raise HTTPException(status_code=404, detail="Key not found")
265+
return {"success": True}
266+
113267
@router.get("/api/v1/admin/storage", dependencies=[Depends(verify_api_key)])
114268
async def get_storage_info():
115269
"""获取当前存储模式"""

app/api/v1/chat.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
from typing import Any, Dict, List, Optional, Union
66

7-
from fastapi import APIRouter
7+
from fastapi import APIRouter, Depends
88
from fastapi.responses import StreamingResponse, JSONResponse
99
from pydantic import BaseModel, Field, field_validator
1010

11+
from app.core.auth import verify_api_key
1112
from app.services.grok.chat import ChatService
1213
from app.services.grok.model import ModelService
1314
from app.core.exceptions import ValidationException
15+
from app.services.quota import enforce_daily_quota
1416

1517

1618
router = APIRouter(tags=["Chat"])
@@ -201,11 +203,14 @@ def validate_request(request: ChatCompletionRequest):
201203

202204

203205
@router.post("/chat/completions")
204-
async def chat_completions(request: ChatCompletionRequest):
206+
async def chat_completions(request: ChatCompletionRequest, api_key: Optional[str] = Depends(verify_api_key)):
205207
"""Chat Completions API - 兼容 OpenAI"""
206208

207209
# 参数验证
208210
validate_request(request)
211+
212+
# Daily quota (best-effort)
213+
await enforce_daily_quota(api_key, request.model)
209214

210215
# 检测视频模型
211216
model_info = ModelService.get(request.model)

app/api/v1/image.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@
66
import random
77
from typing import List, Optional
88

9-
from fastapi import APIRouter
9+
from fastapi import APIRouter, Depends
1010
from fastapi.responses import StreamingResponse, JSONResponse
1111
from pydantic import BaseModel, Field
1212

13+
from app.core.auth import verify_api_key
1314
from app.services.grok.chat import GrokChatService
1415
from app.services.grok.model import ModelService
1516
from app.services.grok.processor import ImageStreamProcessor, ImageCollectProcessor
1617
from app.services.token import get_token_manager
1718
from app.services.request_stats import request_stats
1819
from app.core.exceptions import ValidationException, AppException, ErrorType
1920
from app.core.logger import logger
21+
from app.services.quota import enforce_daily_quota
2022

2123

2224
router = APIRouter(tags=["Images"])
@@ -99,7 +101,7 @@ async def call_grok(token: str, prompt: str, model_info) -> List[str]:
99101

100102

101103
@router.post("/images/generations")
102-
async def create_image(request: ImageGenerationRequest):
104+
async def create_image(request: ImageGenerationRequest, api_key: Optional[str] = Depends(verify_api_key)):
103105
"""
104106
Image Generation API
105107
@@ -116,6 +118,9 @@ async def create_image(request: ImageGenerationRequest):
116118

117119
# 参数验证
118120
validate_request(request)
121+
122+
# Daily quota (best-effort): count by requested n
123+
await enforce_daily_quota(api_key, request.model or "grok-imagine-1.0", image_count=int(request.n or 1))
119124

120125
# 获取 token
121126
try:

app/api/v1/uploads.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Uploads API (used by the web chat UI)
3+
"""
4+
5+
import uuid
6+
from pathlib import Path
7+
8+
import aiofiles
9+
from fastapi import APIRouter, UploadFile, File, HTTPException
10+
11+
from app.services.grok.assets import DownloadService
12+
13+
14+
router = APIRouter(tags=["Uploads"])
15+
16+
BASE_DIR = Path(__file__).parent.parent.parent.parent / "data" / "tmp"
17+
IMAGE_DIR = BASE_DIR / "image"
18+
19+
20+
def _ext_from_mime(mime: str) -> str:
21+
m = (mime or "").lower()
22+
if m == "image/png":
23+
return "png"
24+
if m == "image/webp":
25+
return "webp"
26+
if m == "image/gif":
27+
return "gif"
28+
if m in ("image/jpeg", "image/jpg"):
29+
return "jpg"
30+
return "jpg"
31+
32+
33+
@router.post("/uploads/image")
34+
async def upload_image(file: UploadFile = File(...)):
35+
content_type = (file.content_type or "").lower()
36+
if not content_type.startswith("image/"):
37+
raise HTTPException(status_code=400, detail=f"Unsupported file type: {file.content_type}")
38+
39+
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
40+
name = f"upload-{uuid.uuid4().hex}.{_ext_from_mime(content_type)}"
41+
path = IMAGE_DIR / name
42+
43+
size = 0
44+
async with aiofiles.open(path, "wb") as f:
45+
while True:
46+
chunk = await file.read(1024 * 1024)
47+
if not chunk:
48+
break
49+
size += len(chunk)
50+
await f.write(chunk)
51+
52+
# Best-effort: reuse existing cache cleanup policy (size-based).
53+
try:
54+
dl = DownloadService()
55+
await dl.check_limit()
56+
await dl.close()
57+
except Exception:
58+
pass
59+
60+
return {"url": f"/v1/files/image/{name}", "name": name, "size_bytes": size}
61+
62+
63+
__all__ = ["router"]
64+

0 commit comments

Comments
 (0)