feat: 添加 NSFW 启用功能、Token 筛选标签页及修复 use_count 统计

新增功能:
- 添加 gRPC-Web 协议工具用于 NSFW API 调用
- 添加批量执行工具支持并发控制
- 添加 NSFW 服务及管理 API 端点 POST /api/v1/admin/tokens/nsfw/enable
- 添加 Token 状态筛选标签页(全部/正常/限流/失效)支持 ARIA 无障碍
- 添加批量启用 NSFW 按钮到 Token 管理工具栏

缺陷修复:
- 修复 use_count 统计:在 chat/video/image 完成后调用 consume()
- 修复 use_count 计算:使用 actual_cost 替代 cost 避免过度计数
- 修复 toggleSelectAll 仅选择当前筛选结果

配置变更:
- 添加 NSFW 相关配置项到 config.defaults.toml
This commit is contained in:
piexian committed 2026-02-03 08:00:45 +08:00
1 parent 66fd816fe1
commit 41d5a8d48f
12 files changed
+1171 -316

No files matched your search

+219 -52
View File
@@ -14,42 +14,49 @@ router = APIRouter()
TEMPLATE_DIR = Path(__file__).parent.parent.parent / "static"
async def render_template(filename: str):
"""渲染指定模板"""
template_path = TEMPLATE_DIR / filename
if not template_path.exists():
return HTMLResponse(f"Template {filename} not found.", status_code=404)
async with aiofiles.open(template_path, "r", encoding="utf-8") as f:
content = await f.read()
return HTMLResponse(content)
@router.get("/admin", response_class=HTMLResponse, include_in_schema=False)
async def admin_login_page():
"""管理后台登录页"""
return await render_template("login/login.html")
@router.get("/admin/config", response_class=HTMLResponse, include_in_schema=False)
async def admin_config_page():
"""配置管理页"""
return await render_template("config/config.html")
@router.get("/admin/token", response_class=HTMLResponse, include_in_schema=False)
async def admin_token_page():
"""Token 管理页"""
return await render_template("token/token.html")
@router.post("/api/v1/admin/login", dependencies=[Depends(verify_app_key)])
async def admin_login_api():
"""管理后台登录验证(使用 app_key)"""
return {"status": "success", "api_key": get_config("app.api_key", "")}
@router.get("/api/v1/admin/config", dependencies=[Depends(verify_api_key)])
async def get_config_api():
"""获取当前配置"""
# 暴露原始配置字典
return config._config
@router.post("/api/v1/admin/config", dependencies=[Depends(verify_api_key)])
async def update_config_api(data: dict):
"""更新配置"""
@@ -59,6 +66,7 @@ async def update_config_api(data: dict):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/admin/storage", dependencies=[Depends(verify_api_key)])
async def get_storage_info():
"""获取当前存储模式"""
@@ -81,6 +89,7 @@ async def get_storage_info():
storage_type = storage.dialect
return {"type": storage_type or "local"}
@router.get("/api/v1/admin/tokens", dependencies=[Depends(verify_api_key)])
async def get_tokens_api():
"""获取所有 Token"""
@@ -88,12 +97,14 @@ async def get_tokens_api():
tokens = await storage.load_tokens()
return tokens or {}
@router.post("/api/v1/admin/tokens", dependencies=[Depends(verify_api_key)])
async def update_tokens_api(data: dict):
"""更新 Token 信息"""
storage = get_storage()
try:
from app.services.token.manager import get_token_manager
async with storage.acquire_lock("tokens_save", timeout=10):
await storage.save_tokens(data)
mgr = await get_token_manager()
@@ -102,11 +113,12 @@ async def update_tokens_api(data: dict):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/admin/tokens/refresh", dependencies=[Depends(verify_api_key)])
async def refresh_tokens_api(data: dict):
"""刷新 Token 状态"""
from app.services.token.manager import get_token_manager
try:
mgr = await get_token_manager()
tokens = []
@@ -114,55 +126,163 @@ async def refresh_tokens_api(data: dict):
tokens.append(data["token"])
if "tokens" in data and isinstance(data["tokens"], list):
tokens.extend(data["tokens"])
if not tokens:
raise HTTPException(status_code=400, detail="No tokens provided")
raise HTTPException(status_code=400, detail="No tokens provided")
unique_tokens = list(set(tokens))
sem = asyncio.Semaphore(10)
async def _refresh_one(t):
async with sem:
return t, await mgr.sync_usage(t, "grok-3", consume_on_fail=False, is_usage=False)
return t, await mgr.sync_usage(
t, "grok-3", consume_on_fail=False, is_usage=False
)
results_list = await asyncio.gather(*[_refresh_one(t) for t in unique_tokens])
results = dict(results_list)
return {"status": "success", "results": results}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/admin/tokens/nsfw/enable", dependencies=[Depends(verify_api_key)])
async def enable_nsfw_api(data: dict):
"""批量开启 NSFW (Unhinged) 模式"""
from app.services.grok.nsfw import NSFWService
from app.services.grok.batch import run_in_batches
from app.services.token.manager import get_token_manager
try:
mgr = await get_token_manager()
nsfw_service = NSFWService()
# 收集 token 列表
tokens: list[str] = []
if isinstance(data.get("token"), str) and data["token"].strip():
tokens.append(data["token"].strip())
if isinstance(data.get("tokens"), list):
tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()])
# 若未指定,则使用所有 pool 中的 token
if not tokens:
for pool_name, pool in mgr.pools.items():
for info in pool.list():
raw = (
info.token[4:] if info.token.startswith("sso=") else info.token
)
tokens.append(raw)
if not tokens:
raise HTTPException(status_code=400, detail="No tokens available")
# 去重并保持顺序
unique_tokens = list(dict.fromkeys(tokens))
# 限制最大数量(防止误操作)
max_tokens = get_config("performance.nsfw_max_tokens", 1000)
try:
max_tokens = int(max_tokens)
except Exception:
max_tokens = 1000
if len(unique_tokens) > max_tokens:
raise HTTPException(
status_code=400,
detail=f"Too many tokens: {len(unique_tokens)} > {max_tokens}",
)
# 批量执行配置
max_concurrent = get_config("performance.nsfw_max_concurrent", 10)
batch_size = get_config("performance.admin_nsfw_batch_size", 50)
# 定义 worker
async def _enable(token: str):
result = await nsfw_service.enable(token)
return {
"success": result.success,
"http_status": result.http_status,
"grpc_status": result.grpc_status,
"grpc_message": result.grpc_message,
"error": result.error,
}
# 执行批量操作
raw_results = await run_in_batches(
unique_tokens, _enable, max_concurrent=max_concurrent, batch_size=batch_size
)
# 构造返回结果(mask token)
results = {}
ok_count = 0
fail_count = 0
for token, res in raw_results.items():
masked = f"{token[:8]}...{token[-8:]}" if len(token) > 20 else token
if res.get("ok") and res.get("data", {}).get("success"):
ok_count += 1
results[masked] = res.get("data", {})
else:
fail_count += 1
results[masked] = res.get("data") or {"error": res.get("error")}
return {
"status": "success",
"summary": {
"total": len(unique_tokens),
"ok": ok_count,
"fail": fail_count,
},
"results": results,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Enable NSFW failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/admin/cache", response_class=HTMLResponse, include_in_schema=False)
async def admin_cache_page():
"""缓存管理页"""
return await render_template("cache/cache.html")
@router.get("/api/v1/admin/cache", dependencies=[Depends(verify_api_key)])
async def get_cache_stats_api(request: Request):
"""获取缓存统计"""
from app.services.grok.assets import DownloadService, ListService
from app.services.token.manager import get_token_manager
try:
dl_service = DownloadService()
image_stats = dl_service.get_stats("image")
video_stats = dl_service.get_stats("video")
mgr = await get_token_manager()
pools = mgr.pools
accounts = []
for pool_name, pool in pools.items():
for info in pool.list():
raw_token = info.token[4:] if info.token.startswith("sso=") else info.token
masked = f"{raw_token[:8]}...{raw_token[-16:]}" if len(raw_token) > 24 else raw_token
accounts.append({
"token": raw_token,
"token_masked": masked,
"pool": pool_name,
"status": info.status,
"last_asset_clear_at": info.last_asset_clear_at
})
raw_token = (
info.token[4:] if info.token.startswith("sso=") else info.token
)
masked = (
f"{raw_token[:8]}...{raw_token[-16:]}"
if len(raw_token) > 24
else raw_token
)
accounts.append(
{
"token": raw_token,
"token_masked": masked,
"pool": pool_name,
"status": info.status,
"last_asset_clear_at": info.last_asset_clear_at,
}
)
scope = request.query_params.get("scope")
selected_token = request.query_params.get("token")
@@ -171,7 +291,12 @@ async def get_cache_stats_api(request: Request):
if tokens_param:
selected_tokens = [t.strip() for t in tokens_param.split(",") if t.strip()]
online_stats = {"count": 0, "status": "unknown", "token": None, "last_asset_clear_at": None}
online_stats = {
"count": 0,
"status": "unknown",
"token": None,
"last_asset_clear_at": None,
}
online_details = []
account_map = {a["token"]: a for a in accounts}
batch_size = get_config("performance.admin_assets_batch_size", 10)
@@ -192,42 +317,66 @@ async def get_cache_stats_api(request: Request):
account = account_map.get(token)
try:
count = await _fetch_assets(token)
return ({
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": count,
"status": "ok",
"last_asset_clear_at": account["last_asset_clear_at"] if account else None
}, count)
return (
{
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": count,
"status": "ok",
"last_asset_clear_at": account["last_asset_clear_at"]
if account
else None,
},
count,
)
except Exception as e:
return ({
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": 0,
"status": f"error: {str(e)}",
"last_asset_clear_at": account["last_asset_clear_at"] if account else None
}, 0)
return (
{
"token": token,
"token_masked": account["token_masked"] if account else token,
"count": 0,
"status": f"error: {str(e)}",
"last_asset_clear_at": account["last_asset_clear_at"]
if account
else None,
},
0,
)
if selected_tokens:
total = 0
for i in range(0, len(selected_tokens), batch_size):
chunk = selected_tokens[i:i + batch_size]
results = await asyncio.gather(*[_fetch_detail(token) for token in chunk])
chunk = selected_tokens[i : i + batch_size]
results = await asyncio.gather(
*[_fetch_detail(token) for token in chunk]
)
for detail, count in results:
online_details.append(detail)
total += count
online_stats = {"count": total, "status": "ok" if selected_tokens else "no_token", "token": None, "last_asset_clear_at": None}
online_stats = {
"count": total,
"status": "ok" if selected_tokens else "no_token",
"token": None,
"last_asset_clear_at": None,
}
scope = "selected"
elif scope == "all":
total = 0
tokens = [account["token"] for account in accounts]
for i in range(0, len(tokens), batch_size):
chunk = tokens[i:i + batch_size]
results = await asyncio.gather(*[_fetch_detail(token) for token in chunk])
chunk = tokens[i : i + batch_size]
results = await asyncio.gather(
*[_fetch_detail(token) for token in chunk]
)
for detail, count in results:
online_details.append(detail)
total += count
online_stats = {"count": total, "status": "ok" if accounts else "no_token", "token": None, "last_asset_clear_at": None}
online_stats = {
"count": total,
"status": "ok" if accounts else "no_token",
"token": None,
"last_asset_clear_at": None,
}
else:
token = selected_token
if token:
@@ -239,7 +388,9 @@ async def get_cache_stats_api(request: Request):
"status": "ok",
"token": token,
"token_masked": match["token_masked"] if match else token,
"last_asset_clear_at": match["last_asset_clear_at"] if match else None
"last_asset_clear_at": match["last_asset_clear_at"]
if match
else None,
}
except Exception as e:
match = next((a for a in accounts if a["token"] == token), None)
@@ -248,28 +399,37 @@ async def get_cache_stats_api(request: Request):
"status": f"error: {str(e)}",
"token": token,
"token_masked": match["token_masked"] if match else token,
"last_asset_clear_at": match["last_asset_clear_at"] if match else None
"last_asset_clear_at": match["last_asset_clear_at"]
if match
else None,
}
else:
online_stats = {"count": 0, "status": "not_loaded", "token": None, "last_asset_clear_at": None}
online_stats = {
"count": 0,
"status": "not_loaded",
"token": None,
"last_asset_clear_at": None,
}
return {
"local_image": image_stats,
"local_video": video_stats,
"online": online_stats,
"online_accounts": accounts,
"online_scope": scope or "none",
"online_details": online_details
"online_details": online_details,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/admin/cache/clear", dependencies=[Depends(verify_api_key)])
async def clear_local_cache_api(data: dict):
"""清理本地缓存"""
from app.services.grok.assets import DownloadService
cache_type = data.get("type", "image")
try:
dl_service = DownloadService()
result = dl_service.clear(cache_type)
@@ -277,15 +437,17 @@ async def clear_local_cache_api(data: dict):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/admin/cache/list", dependencies=[Depends(verify_api_key)])
async def list_local_cache_api(
cache_type: str = "image",
type_: str = Query(default=None, alias="type"),
page: int = 1,
page_size: int = 1000
page_size: int = 1000,
):
"""列出本地缓存文件"""
from app.services.grok.assets import DownloadService
try:
if type_:
cache_type = type_
@@ -295,10 +457,12 @@ async def list_local_cache_api(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/admin/cache/item/delete", dependencies=[Depends(verify_api_key)])
async def delete_local_cache_item_api(data: dict):
"""删除单个本地缓存文件"""
from app.services.grok.assets import DownloadService
cache_type = data.get("type", "image")
name = data.get("name")
if not name:
@@ -310,12 +474,13 @@ async def delete_local_cache_item_api(data: dict):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/admin/cache/online/clear", dependencies=[Depends(verify_api_key)])
async def clear_online_cache_api(data: dict):
"""清理在线缓存"""
from app.services.grok.assets import DeleteService
from app.services.token.manager import get_token_manager
delete_service = None
try:
mgr = await get_token_manager()
@@ -344,7 +509,7 @@ async def clear_online_cache_api(data: dict):
return t, {"status": "error", "error": str(e)}
for i in range(0, len(token_list), batch_size):
chunk = token_list[i:i + batch_size]
chunk = token_list[i : i + batch_size]
res_list = await asyncio.gather(*[_clear_one(t) for t in chunk])
for t, res in res_list:
results[t] = res
@@ -353,7 +518,9 @@ async def clear_online_cache_api(data: dict):
token = data.get("token") or mgr.get_token()
if not token:
raise HTTPException(status_code=400, detail="No available token to perform cleanup")
raise HTTPException(
status_code=400, detail="No available token to perform cleanup"
)
result = await delete_service.delete_all(token)
await mgr.mark_asset_clear(token)
+78 -48
View File
@@ -13,7 +13,7 @@ from pydantic import BaseModel, Field
from app.services.grok.chat import GrokChatService
from app.services.grok.model import ModelService
from app.services.grok.processor import ImageStreamProcessor, ImageCollectProcessor
from app.services.token import get_token_manager
from app.services.token import get_token_manager, EffortType
from app.core.exceptions import ValidationException, AppException, ErrorType
from app.core.logger import logger
@@ -23,6 +23,7 @@ router = APIRouter(tags=["Images"])
class ImageGenerationRequest(BaseModel):
"""图片生成请求 - OpenAI 兼容"""
prompt: str = Field(..., description="图片描述")
model: Optional[str] = Field("grok-imagine-1.0", description="模型名称")
n: Optional[int] = Field(1, ge=1, le=10, description="生成数量 (1-10)")
@@ -43,40 +44,36 @@ def validate_request(request: ImageGenerationRequest):
raise ValidationException(
message=f"The model `{request.model}` is not supported for image generation. Supported: {image_models}",
param="model",
code="model_not_supported"
code="model_not_supported",
)
# 验证 prompt
if not request.prompt or not request.prompt.strip():
raise ValidationException(
message="Prompt cannot be empty",
param="prompt",
code="empty_prompt"
message="Prompt cannot be empty", param="prompt", code="empty_prompt"
)
# 验证 n 参数范围
if request.n < 1 or request.n > 10:
raise ValidationException(
message="n must be between 1 and 10",
param="n",
code="invalid_n"
message="n must be between 1 and 10", param="n", code="invalid_n"
)
# 流式只支持 n=1 或 n=2
if request.stream and request.n not in [1, 2]:
raise ValidationException(
message="Streaming is only supported when n=1 or n=2",
param="stream",
code="invalid_stream_n"
code="invalid_stream_n",
)
async def call_grok(token: str, prompt: str, model_info) -> List[str]:
async def call_grok(token_mgr, token: str, prompt: str, model_info) -> List[str]:
"""
调用 Grok 获取图片,返回 base64 列表
"""
chat_service = GrokChatService()
try:
response = await chat_service.chat(
token=token,
@@ -84,38 +81,49 @@ async def call_grok(token: str, prompt: str, model_info) -> List[str]:
model=model_info.grok_model,
mode=model_info.model_mode,
think=False,
stream=True
stream=True,
)
# 收集图片
processor = ImageCollectProcessor(model_info.model_id, token)
images = await processor.process(response)
return images
except Exception as e:
logger.error(f"Grok image call failed: {e}")
return []
finally:
# 记录使用
try:
effort = (
EffortType.HIGH
if (model_info and model_info.cost.value == "high")
else EffortType.LOW
)
await token_mgr.consume(token, effort)
except Exception as e:
logger.warning(f"Failed to consume token: {e}")
@router.post("/images/generations")
async def create_image(request: ImageGenerationRequest):
"""
Image Generation API
流式响应格式:
- event: image_generation.partial_image
- event: image_generation.completed
非流式响应格式:
- {"created": ..., "data": [{"b64_json": "..."}], "usage": {...}}
"""
# stream 默认为 false
if request.stream is None:
request.stream = False
# 参数验证
validate_request(request)
# 获取 token
try:
token_mgr = await get_token_manager()
@@ -127,7 +135,7 @@ async def create_image(request: ImageGenerationRequest):
raise AppException(
message="Internal service error obtaining token",
error_type=ErrorType.SERVER.value,
code="internal_error"
code="internal_error",
)
if not token:
@@ -135,12 +143,12 @@ async def create_image(request: ImageGenerationRequest):
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429
status_code=429,
)
# 获取模型信息
model_info = ModelService.get(request.model)
# 流式模式
if request.stream:
chat_service = GrokChatService()
@@ -150,32 +158,49 @@ async def create_image(request: ImageGenerationRequest):
model=model_info.grok_model,
mode=model_info.model_mode,
think=False,
stream=True
stream=True,
)
processor = ImageStreamProcessor(model_info.model_id, token, n=request.n)
# 包装流式响应,在完成时记录使用
async def _wrap_stream(stream):
try:
async for chunk in stream:
yield chunk
finally:
try:
effort = (
EffortType.HIGH
if (model_info and model_info.cost.value == "high")
else EffortType.LOW
)
await token_mgr.consume(token, effort)
except Exception as e:
logger.warning(f"Failed to consume token: {e}")
return StreamingResponse(
processor.process(response),
_wrap_stream(processor.process(response)),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
# 非流式模式
n = request.n
calls_needed = (n + 1) // 2
calls_needed = (n + 1) // 2
if calls_needed == 1:
# 单次调用
all_images = await call_grok(token, request.prompt, model_info)
all_images = await call_grok(token_mgr, token, request.prompt, model_info)
else:
# 并发调用
tasks = [
call_grok(token, request.prompt, model_info)
call_grok(token_mgr, token, request.prompt, model_info)
for _ in range(calls_needed)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 收集成功的图片
all_images = []
for result in results:
@@ -183,7 +208,7 @@ async def create_image(request: ImageGenerationRequest):
logger.error(f"Concurrent call failed: {result}")
elif isinstance(result, list):
all_images.extend(result)
# 随机选取 n 张图片
if len(all_images) >= n:
selected_images = random.sample(all_images, n)
@@ -192,21 +217,26 @@ async def create_image(request: ImageGenerationRequest):
selected_images = all_images.copy()
while len(selected_images) < n:
selected_images.append("error")
# 构建响应
import time
data = [{"b64_json": img} for img in selected_images]
return JSONResponse(content={
"created": int(time.time()),
"data": data,
"usage": {
"total_tokens": 0 * len([img for img in selected_images if img != "error"]),
"input_tokens": 0,
"output_tokens": 0 * len([img for img in selected_images if img != "error"]),
"input_tokens_details": {"text_tokens": 0, "image_tokens": 0}
return JSONResponse(
content={
"created": int(time.time()),
"data": data,
"usage": {
"total_tokens": 0
* len([img for img in selected_images if img != "error"]),
"input_tokens": 0,
"output_tokens": 0
* len([img for img in selected_images if img != "error"]),
"input_tokens_details": {"text_tokens": 0, "image_tokens": 0},
},
}
})
)
__all__ = ["router"]
+68
View File
@@ -0,0 +1,68 @@
"""
批量执行工具
提供分批并发、单项失败隔离的通用批量处理能力。
"""
import asyncio
from typing import Any, Awaitable, Callable, Dict, List, TypeVar
from app.core.logger import logger
T = TypeVar("T")
async def run_in_batches(
items: List[str],
worker: Callable[[str], Awaitable[T]],
*,
max_concurrent: int = 10,
batch_size: int = 50,
) -> Dict[str, Dict[str, Any]]:
"""
分批并发执行,单项失败不影响整体
Args:
items: 待处理项列表
worker: 异步处理函数
max_concurrent: 最大并发数
batch_size: 每批大小
Returns:
{item: {"ok": bool, "data": ..., "error": ...}}
"""
try:
max_concurrent = int(max_concurrent)
except Exception:
max_concurrent = 10
try:
batch_size = int(batch_size)
except Exception:
batch_size = 50
max_concurrent = max(1, max_concurrent)
batch_size = max(1, batch_size)
sem = asyncio.Semaphore(max_concurrent)
async def _one(item: str) -> tuple[str, dict]:
async with sem:
try:
data = await worker(item)
return item, {"ok": True, "data": data}
except Exception as e:
logger.warning(f"Batch item failed: {item[:16]}... - {e}")
return item, {"ok": False, "error": str(e)}
results: Dict[str, dict] = {}
# 分批执行,避免一次性创建所有 task
for i in range(0, len(items), batch_size):
chunk = items[i : i + batch_size]
pairs = await asyncio.gather(*(_one(x) for x in chunk))
results.update(dict(pairs))
return results
__all__ = ["run_in_batches"]
+158 -84
View File
@@ -4,7 +4,7 @@ Grok Chat 服务
import uuid
import orjson
from typing import Dict, List, Any
from typing import Dict, List, Any, AsyncGenerator
from dataclasses import dataclass
from curl_cffi.requests import AsyncSession
@@ -12,17 +12,17 @@ from curl_cffi.requests import AsyncSession
from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import (
AppException,
AppException,
UpstreamException,
ValidationException,
ErrorType
ErrorType,
)
from app.services.grok.statsig import StatsigService
from app.services.grok.model import ModelService
from app.services.grok.assets import UploadService
from app.services.grok.processor import StreamProcessor, CollectProcessor
from app.services.grok.retry import retry_on_status
from app.services.token import get_token_manager
from app.services.token import get_token_manager, EffortType
CHAT_API = "https://grok.com/rest/app-chat/conversations/new"
@@ -33,6 +33,7 @@ BROWSER = "chrome136"
@dataclass
class ChatRequest:
"""聊天请求数据"""
model: str
messages: List[Dict[str, Any]]
stream: bool = None
@@ -41,24 +42,26 @@ class ChatRequest:
class MessageExtractor:
"""消息内容提取器"""
# 需要上传的类型
UPLOAD_TYPES = {"image_url", "input_audio", "file"}
# 视频模式不支持的类型
VIDEO_UNSUPPORTED = {"input_audio", "file"}
@staticmethod
def extract(messages: List[Dict[str, Any]], is_video: bool = False) -> tuple[str, List[str]]:
def extract(
messages: List[Dict[str, Any]], is_video: bool = False
) -> tuple[str, List[str]]:
"""
从 OpenAI 消息格式提取内容
Args:
messages: OpenAI 格式消息列表
is_video: 是否为视频模型
Returns:
(text, attachments): 拼接后的文本和需要上传的附件列表
Raises:
ValueError: 视频模型遇到不支持的内容类型
"""
@@ -92,7 +95,11 @@ class MessageExtractor:
# 图片类型
elif item_type == "image_url":
image_data = item.get("image_url", {})
url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data)
url = (
image_data.get("url", "")
if isinstance(image_data, dict)
else str(image_data)
)
if url:
attachments.append(("image", url))
@@ -101,7 +108,11 @@ class MessageExtractor:
if is_video:
raise ValueError("视频模型不支持 input_audio 类型")
audio_data = item.get("input_audio", {})
data = audio_data.get("data", "") if isinstance(audio_data, dict) else str(audio_data)
data = (
audio_data.get("data", "")
if isinstance(audio_data, dict)
else str(audio_data)
)
if data:
attachments.append(("audio", data))
@@ -138,7 +149,7 @@ class MessageExtractor:
# 换行拼接文本
message = "\n\n".join(texts)
return message, attachments
@staticmethod
def extract_text_only(messages: List[Dict[str, Any]]) -> str:
"""仅提取文本内容"""
@@ -148,7 +159,7 @@ class MessageExtractor:
class ChatRequestBuilder:
"""请求构造器"""
@staticmethod
def build_headers(token: str) -> Dict[str, str]:
"""构造请求头"""
@@ -174,30 +185,30 @@ class ChatRequestBuilder:
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
}
# Statsig ID
headers["x-statsig-id"] = StatsigService.gen_id()
headers["x-xai-request-id"] = str(uuid.uuid4())
# Cookie
token = token[4:] if token.startswith("sso=") else token
cf = get_config("grok.cf_clearance", "")
headers["Cookie"] = f"sso={token};cf_clearance={cf}" if cf else f"sso={token}"
return headers
@staticmethod
def build_payload(
message: str,
model: str,
mode: str,
message: str,
model: str,
mode: str,
think: bool = None,
file_attachments: List[str] = None,
image_attachments: List[str] = None
image_attachments: List[str] = None,
) -> Dict[str, Any]:
"""
构造请求体
Args:
message: 消息文本
model: 模型名称
@@ -209,7 +220,7 @@ class ChatRequestBuilder:
temporary = get_config("grok.temporary", True)
if think is None:
think = get_config("grok.thinking", False)
return {
"temporary": temporary,
"modelName": model,
@@ -231,7 +242,7 @@ class ChatRequestBuilder:
"disableTextFollowUps": False,
"responseMetadata": {
"modelConfigOverride": {"modelMap": {}},
"requestModelDetails": {"modelId": model}
"requestModelDetails": {"modelId": model},
},
"disableMemory": False,
"forceSideBySide": False,
@@ -243,19 +254,20 @@ class ChatRequestBuilder:
"screenWidth": 2056,
"screenHeight": 1329,
"viewportWidth": 2056,
"viewportHeight": 1083
}
"viewportHeight": 1083,
},
}
# ==================== Grok 服务 ====================
class GrokChatService:
"""Grok API 调用服务"""
def __init__(self, proxy: str = None):
self.proxy = proxy or get_config("grok.base_proxy_url", "")
async def chat(
self,
token: str,
@@ -265,11 +277,11 @@ class GrokChatService:
think: bool = None,
stream: bool = None,
file_attachments: List[str] = None,
image_attachments: List[str] = None
image_attachments: List[str] = None,
):
"""
发送聊天请求
Args:
token: 认证 Token
message: 消息文本
@@ -279,27 +291,26 @@ class GrokChatService:
stream: 是否流式
file_attachments: 文件附件 ID 列表
image_attachments: 图片附件 URL 列表
Raises:
UpstreamException: 当 Grok API 返回错误且重试耗尽时
"""
if stream is None:
stream = get_config("grok.stream", True)
headers = ChatRequestBuilder.build_headers(token)
payload = ChatRequestBuilder.build_payload(
message, model, mode, think,
file_attachments, image_attachments
message, model, mode, think, file_attachments, image_attachments
)
proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None
timeout = get_config("grok.timeout", TIMEOUT)
# 状态码提取器
def extract_status(e: Exception) -> int | None:
if isinstance(e, UpstreamException) and e.details:
return e.details.get("status")
return None
# 建立连接函数
async def establish_connection():
"""建立连接并返回 response 对象"""
@@ -311,19 +322,22 @@ class GrokChatService:
data=orjson.dumps(payload),
timeout=timeout,
stream=True,
proxies=proxies
proxies=proxies,
)
if response.status_code != 200:
try:
content = await response.text()
content = content[:1000] # 限制长度避免日志过大
content = content[:1000] # 限制长度避免日志过大
except:
content = "Unable to read response content"
logger.error(
f"Chat failed: {response.status_code}, {content}",
extra={"status": response.status_code, "token": token[:10] + "..."}
extra={
"status": response.status_code,
"token": token[:10] + "...",
},
)
# 关闭 session 并抛出异常
try:
@@ -332,12 +346,12 @@ class GrokChatService:
pass
raise UpstreamException(
message=f"Grok API request failed: {response.status_code}",
details={"status": response.status_code}
details={"status": response.status_code},
)
# 返回 session 和 response
return session, response
except UpstreamException:
# 已经处理过的异常,直接抛出
raise
@@ -350,16 +364,15 @@ class GrokChatService:
pass
raise UpstreamException(
message=f"Chat connection failed: {str(e)}",
details={"error": str(e)}
details={"error": str(e)},
)
# 建立连接
session = None
response = None
try:
session, response = await retry_on_status(
establish_connection,
extract_status=extract_status
establish_connection, extract_status=extract_status
)
except Exception as e:
# 记录失败
@@ -368,7 +381,7 @@ class GrokChatService:
token_mgr = await get_token_manager()
await token_mgr.record_fail(token, status_code, str(e))
raise
# 流式传输
async def stream_response():
try:
@@ -377,36 +390,38 @@ class GrokChatService:
finally:
if session:
await session.close()
return stream_response()
async def chat_openai(self, token: str, request: ChatRequest):
"""OpenAI 兼容接口"""
model_info = ModelService.get(request.model)
if not model_info:
raise ValidationException(f"Unknown model: {request.model}")
grok_model = model_info.grok_model
mode = model_info.model_mode
is_video = model_info.is_video
# 提取消息和附件
try:
message, attachments = MessageExtractor.extract(request.messages, is_video=is_video)
message, attachments = MessageExtractor.extract(
request.messages, is_video=is_video
)
except ValueError as e:
raise ValidationException(str(e))
# 处理附件上传
file_ids = []
image_ids = []
if attachments:
upload_service = UploadService()
try:
for attach_type, attach_data in attachments:
# 获取 ID
file_id, _ = await upload_service.upload(attach_data, token)
if attach_type == "image":
# 图片 imageAttachments
image_ids.append(file_id)
@@ -417,40 +432,84 @@ class GrokChatService:
logger.debug(f"File uploaded: {file_id}")
finally:
await upload_service.close()
stream = request.stream if request.stream is not None else get_config("grok.stream", True)
think = request.think if request.think is not None else get_config("grok.thinking", False)
response = await self.chat(
token, message, grok_model, mode, think, stream,
file_attachments=file_ids,
image_attachments=image_ids
stream = (
request.stream
if request.stream is not None
else get_config("grok.stream", True)
)
think = (
request.think
if request.think is not None
else get_config("grok.thinking", False)
)
response = await self.chat(
token,
message,
grok_model,
mode,
think,
stream,
file_attachments=file_ids,
image_attachments=image_ids,
)
return response, stream, request.model
# ==================== Chat 业务服务 ====================
class ChatService:
"""Chat 业务服务"""
@staticmethod
async def _wrap_stream(stream: AsyncGenerator, token_mgr, token: str, model: str):
"""
包装流式响应,在完成时记录使用
Args:
stream: 原始 AsyncGenerator
token_mgr: TokenManager 实例
token: Token 字符串
model: 模型名称
"""
try:
async for chunk in stream:
yield chunk
finally:
# 无论成功完成还是客户端断开,都记录使用
try:
model_info = ModelService.get(model)
effort = (
EffortType.HIGH
if (model_info and model_info.cost.value == "high")
else EffortType.LOW
)
await token_mgr.consume(token, effort)
logger.debug(
f"Stream completed, recorded usage for token {token[:10]}... (effort={effort.value})"
)
except Exception as e:
logger.warning(f"Failed to record stream usage: {e}")
@staticmethod
async def completions(
model: str,
messages: List[Dict[str, Any]],
stream: bool = None,
thinking: str = None
thinking: str = None,
):
"""
Chat Completions 入口
Args:
model: 模型名称
messages: 消息列表
stream: 是否流式
thinking: 思考模式
Returns:
AsyncGenerator 或 dict
"""
@@ -465,7 +524,7 @@ class ChatService:
raise AppException(
message="Internal service error obtaining token",
error_type=ErrorType.SERVER.value,
code="internal_error"
code="internal_error",
)
if not token:
@@ -473,26 +532,23 @@ class ChatService:
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429
status_code=429,
)
# 解析参数
think = None
if thinking == "enabled":
think = True
elif thinking == "disabled":
think = False
is_stream = stream if stream is not None else get_config("grok.stream", True)
# 构造请求
chat_request = ChatRequest(
model=model,
messages=messages,
stream=is_stream,
think=think
model=model, messages=messages, stream=is_stream, think=think
)
# 请求 Grok
service = GrokChatService()
try:
@@ -503,14 +559,32 @@ class ChatService:
logger.error(f"Chat service error: {e}")
raise UpstreamException(
message=f"Service processing failed: {str(e)}",
details={"error": str(e)}
details={"error": str(e)},
)
# 处理响应
if is_stream:
return StreamProcessor(model_name, token, think).process(response)
processor = StreamProcessor(model_name, token, think)
return ChatService._wrap_stream(
processor.process(response), token_mgr, token, model
)
else:
return await CollectProcessor(model_name, token).process(response)
result = await CollectProcessor(model_name, token).process(response)
# 非流式:处理完成后立即记录使用
try:
model_info = ModelService.get(model)
effort = (
EffortType.HIGH
if (model_info and model_info.cost.value == "high")
else EffortType.LOW
)
await token_mgr.consume(token, effort)
logger.debug(
f"Collect completed, recorded usage for token {token[:10]}... (effort={effort.value})"
)
except Exception as e:
logger.warning(f"Failed to record collect usage: {e}")
return result
__all__ = [
+157
View File
@@ -0,0 +1,157 @@
"""
gRPC-Web 协议工具
提供 framing 编码/解码、trailer 解析等通用功能。
支持 application/grpc-web+proto 和 application/grpc-web-text (base64) 两种格式。
"""
from __future__ import annotations
import base64
import re
import struct
from dataclasses import dataclass
from typing import Dict, List, Mapping, Tuple
from urllib.parse import unquote
_B64_RE = re.compile(rb"^[A-Za-z0-9+/=\r\n]+$")
def encode_grpc_web_payload(data: bytes) -> bytes:
"""
编码 gRPC-Web data frame
Frame format:
1-byte flags + 4-byte big-endian length + message bytes
"""
return b"\x00" + struct.pack(">I", len(data)) + data
def _maybe_decode_grpc_web_text(body: bytes, content_type: str | None) -> bytes:
"""处理 grpc-web-text 模式的 base64 解码"""
ct = (content_type or "").lower()
if "grpc-web-text" in ct:
compact = b"".join(body.split())
return base64.b64decode(compact, validate=False)
# 启发式:body 仅包含 base64 字符才尝试解码
head = body[: min(len(body), 2048)]
if head and _B64_RE.fullmatch(head):
compact = b"".join(body.split())
try:
return base64.b64decode(compact, validate=True)
except Exception:
return body
return body
def _parse_trailer_block(payload: bytes) -> Dict[str, str]:
"""解析 trailer frame 内容"""
text = payload.decode("utf-8", errors="replace")
lines = [ln for ln in re.split(r"\r\n|\n", text) if ln]
trailers: Dict[str, str] = {}
for ln in lines:
if ":" not in ln:
continue
k, v = ln.split(":", 1)
trailers[k.strip().lower()] = v.strip()
# grpc-message 可能是 percent-encoding
if "grpc-message" in trailers:
trailers["grpc-message"] = unquote(trailers["grpc-message"])
return trailers
def parse_grpc_web_response(
body: bytes,
content_type: str | None = None,
headers: Mapping[str, str] | None = None,
) -> Tuple[List[bytes], Dict[str, str]]:
"""
解析 gRPC-Web 响应
Returns:
(messages, trailers): data frames 列表和合并后的 trailers
"""
decoded = _maybe_decode_grpc_web_text(body, content_type)
messages: List[bytes] = []
trailers: Dict[str, str] = {}
i = 0
n = len(decoded)
while i < n:
if n - i < 5:
break
flag = decoded[i]
length = int.from_bytes(decoded[i + 1 : i + 5], "big")
i += 5
if n - i < length:
break
payload = decoded[i : i + length]
i += length
if flag & 0x80: # trailer frame
trailers.update(_parse_trailer_block(payload))
elif flag & 0x01: # compressed (不支持)
raise ValueError("grpc-web compressed flag not supported")
else:
messages.append(payload)
# 兼容:grpc-status 可能在 response headers 中
if headers:
lower = {k.lower(): v for k, v in headers.items()}
if "grpc-status" in lower and "grpc-status" not in trailers:
trailers["grpc-status"] = str(lower["grpc-status"]).strip()
if "grpc-message" in lower and "grpc-message" not in trailers:
trailers["grpc-message"] = unquote(str(lower["grpc-message"]).strip())
return messages, trailers
@dataclass(frozen=True)
class GrpcStatus:
code: int
message: str = ""
@property
def ok(self) -> bool:
return self.code == 0
@property
def http_equiv(self) -> int:
"""映射到类 HTTP 状态码"""
mapping = {
0: 200, # OK
16: 401, # UNAUTHENTICATED
7: 403, # PERMISSION_DENIED
8: 429, # RESOURCE_EXHAUSTED
4: 504, # DEADLINE_EXCEEDED
14: 503, # UNAVAILABLE
}
return mapping.get(self.code, 502)
def get_grpc_status(trailers: Mapping[str, str]) -> GrpcStatus:
"""从 trailers 提取 gRPC 状态"""
raw = str(trailers.get("grpc-status", "")).strip()
msg = str(trailers.get("grpc-message", "")).strip()
try:
code = int(raw)
except Exception:
code = -1
return GrpcStatus(code=code, message=msg)
__all__ = [
"encode_grpc_web_payload",
"parse_grpc_web_response",
"get_grpc_status",
"GrpcStatus",
]
+156 -93
View File
@@ -11,10 +11,15 @@ from curl_cffi.requests import AsyncSession
from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import UpstreamException, AppException, ValidationException, ErrorType
from app.core.exceptions import (
UpstreamException,
AppException,
ValidationException,
ErrorType,
)
from app.services.grok.statsig import StatsigService
from app.services.grok.model import ModelService
from app.services.token import get_token_manager
from app.services.token import get_token_manager, EffortType
from app.services.grok.processor import VideoStreamProcessor, VideoCollectProcessor
# API 端点
@@ -28,6 +33,7 @@ DEFAULT_MAX_CONCURRENT = 50
_MEDIA_SEMAPHORE = asyncio.Semaphore(DEFAULT_MAX_CONCURRENT)
_MEDIA_SEM_VALUE = DEFAULT_MAX_CONCURRENT
def _get_media_semaphore() -> asyncio.Semaphore:
global _MEDIA_SEMAPHORE, _MEDIA_SEM_VALUE
value = get_config("performance.media_max_concurrent", DEFAULT_MAX_CONCURRENT)
@@ -44,12 +50,14 @@ def _get_media_semaphore() -> asyncio.Semaphore:
class VideoService:
"""视频生成服务"""
def __init__(self, proxy: str = None):
self.proxy = proxy or get_config("grok.base_proxy_url", "")
self.timeout = get_config("grok.timeout", TIMEOUT)
def _build_headers(self, token: str, referer: str = "https://grok.com/imagine") -> dict:
def _build_headers(
self, token: str, referer: str = "https://grok.com/imagine"
) -> dict:
"""构建请求头"""
headers = {
"Accept": "*/*",
@@ -73,50 +81,50 @@ class VideoService:
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
}
# Statsig ID
headers["x-statsig-id"] = StatsigService.gen_id()
headers["x-xai-request-id"] = str(uuid.uuid4())
# Cookie
token = token[4:] if token.startswith("sso=") else token
cf = get_config("grok.cf_clearance", "")
headers["Cookie"] = f"sso={token};cf_clearance={cf}" if cf else f"sso={token}"
return headers
def _build_proxies(self) -> Optional[dict]:
"""构建代理"""
return {"http": self.proxy, "https": self.proxy} if self.proxy else None
async def create_post(self, token: str, prompt: str, media_type: str = "MEDIA_POST_TYPE_VIDEO", media_url: str = None) -> str:
async def create_post(
self,
token: str,
prompt: str,
media_type: str = "MEDIA_POST_TYPE_VIDEO",
media_url: str = None,
) -> str:
"""
创建媒体帖子
Args:
token: 认证 Token
prompt: 提示词(视频生成用)
media_type: 媒体类型 (MEDIA_POST_TYPE_VIDEO 或 MEDIA_POST_TYPE_IMAGE)
media_url: 媒体 URL(图片模式用)
Returns:
post ID
"""
try:
headers = self._build_headers(token)
# 根据类型构建不同的载荷
if media_type == "MEDIA_POST_TYPE_IMAGE" and media_url:
payload = {
"mediaType": media_type,
"mediaUrl": media_url
}
payload = {"mediaType": media_type, "mediaUrl": media_url}
else:
payload = {
"mediaType": media_type,
"prompt": prompt
}
payload = {"mediaType": media_type, "prompt": prompt}
async with AsyncSession() as session:
response = await session.post(
CREATE_POST_API,
@@ -124,46 +132,45 @@ class VideoService:
json=payload,
impersonate=BROWSER,
timeout=30,
proxies=self._build_proxies()
proxies=self._build_proxies(),
)
if response.status_code != 200:
logger.error(f"Create post failed: {response.status_code}")
raise UpstreamException(f"Failed to create post: {response.status_code}")
raise UpstreamException(
f"Failed to create post: {response.status_code}"
)
data = response.json()
post_id = data.get("post", {}).get("id", "")
if not post_id:
raise UpstreamException("No post ID in response")
logger.info(f"Media post created: {post_id} (type={media_type})")
return post_id
except Exception as e:
logger.error(f"Create post error: {e}")
if isinstance(e, AppException):
raise e
raise UpstreamException(f"Create post error: {str(e)}")
async def create_image_post(self, token: str, image_url: str) -> str:
"""
创建图片帖子
Args:
token: 认证 Token
image_url: 完整的图片 URL (https://assets.grok.com/...)
Returns:
post ID
"""
return await self.create_post(
token,
prompt="",
media_type="MEDIA_POST_TYPE_IMAGE",
media_url=image_url
token, prompt="", media_type="MEDIA_POST_TYPE_IMAGE", media_url=image_url
)
def _build_payload(
self,
prompt: str,
@@ -171,7 +178,7 @@ class VideoService:
aspect_ratio: str = "3:2",
video_length: int = 6,
resolution: str = "SD",
preset: str = "normal"
preset: str = "normal",
) -> dict:
"""构建视频生成载荷"""
mode_flag = "--mode=custom"
@@ -181,9 +188,9 @@ class VideoService:
mode_flag = "--mode=normal"
elif preset == "spicy":
mode_flag = "--mode=extremely-spicy-or-crazy"
full_prompt = f"{prompt} {mode_flag}"
return {
"temporary": True,
"modelName": "grok-3",
@@ -198,13 +205,13 @@ class VideoService:
"parentPostId": post_id,
"aspectRatio": aspect_ratio,
"videoLength": video_length,
"videoResolution": resolution
"videoResolution": resolution,
}
}
}
}
},
},
}
async def generate(
self,
token: str,
@@ -213,11 +220,11 @@ class VideoService:
video_length: int = 6,
resolution: str = "SD",
stream: bool = True,
preset: str = "normal"
preset: str = "normal",
) -> AsyncGenerator[bytes, None]:
"""
生成视频
Args:
token: 认证 Token
prompt: 视频描述
@@ -226,10 +233,10 @@ class VideoService:
resolution: 分辨率
stream: 是否流式
preset: 预设
Returns:
AsyncGenerator,流式传输
Raises:
UpstreamException: 连接失败时
"""
@@ -238,11 +245,13 @@ class VideoService:
try:
# Step 1: 创建帖子
post_id = await self.create_post(token, prompt)
# Step 2: 建立连接
headers = self._build_headers(token)
payload = self._build_payload(prompt, post_id, aspect_ratio, video_length, resolution, preset)
payload = self._build_payload(
prompt, post_id, aspect_ratio, video_length, resolution, preset
)
session = AsyncSession(impersonate=BROWSER)
response = await session.post(
CHAT_API,
@@ -250,9 +259,9 @@ class VideoService:
data=orjson.dumps(payload),
timeout=self.timeout,
stream=True,
proxies=self._build_proxies()
proxies=self._build_proxies(),
)
if response.status_code != 200:
logger.error(f"Video generation failed: {response.status_code}")
try:
@@ -261,9 +270,9 @@ class VideoService:
pass
raise UpstreamException(
message=f"Video generation failed: {response.status_code}",
details={"status": response.status_code}
details={"status": response.status_code},
)
# Step 3: 流式传输
async def stream_response():
try:
@@ -272,9 +281,9 @@ class VideoService:
finally:
if session:
await session.close()
return stream_response()
except Exception as e:
if session:
try:
@@ -285,7 +294,7 @@ class VideoService:
if isinstance(e, AppException):
raise e
raise UpstreamException(f"Video generation error: {str(e)}")
async def generate_from_image(
self,
token: str,
@@ -295,11 +304,11 @@ class VideoService:
video_length: int = 6,
resolution: str = "SD",
stream: bool = True,
preset: str = "normal"
preset: str = "normal",
) -> AsyncGenerator[bytes, None]:
"""
从图片生成视频
Args:
token: 认证 Token
prompt: 视频描述
@@ -309,7 +318,7 @@ class VideoService:
resolution: 分辨率
stream: 是否流式
preset: 预设
Returns:
AsyncGenerator,流式传输
"""
@@ -318,11 +327,13 @@ class VideoService:
try:
# Step 1: 创建帖子
post_id = await self.create_image_post(token, image_url)
# Step 2: 建立连接
headers = self._build_headers(token)
payload = self._build_payload(prompt, post_id, aspect_ratio, video_length, resolution, preset)
payload = self._build_payload(
prompt, post_id, aspect_ratio, video_length, resolution, preset
)
session = AsyncSession(impersonate=BROWSER)
response = await session.post(
CHAT_API,
@@ -330,9 +341,9 @@ class VideoService:
data=orjson.dumps(payload),
timeout=self.timeout,
stream=True,
proxies=self._build_proxies()
proxies=self._build_proxies(),
)
if response.status_code != 200:
logger.error(f"Video from image failed: {response.status_code}")
try:
@@ -341,9 +352,9 @@ class VideoService:
pass
raise UpstreamException(
message=f"Video from image failed: {response.status_code}",
details={"status": response.status_code}
details={"status": response.status_code},
)
# Step 3: 流式传输
async def stream_response():
try:
@@ -352,9 +363,9 @@ class VideoService:
finally:
if session:
await session.close()
return stream_response()
except Exception as e:
if session:
try:
@@ -365,7 +376,36 @@ class VideoService:
if isinstance(e, AppException):
raise e
raise UpstreamException(f"Video from image error: {str(e)}")
@staticmethod
async def _wrap_stream(stream: AsyncGenerator, token_mgr, token: str, model: str):
"""
包装流式响应,在完成时记录使用
Args:
stream: 原始 AsyncGenerator
token_mgr: TokenManager 实例
token: Token 字符串
model: 模型名称
"""
try:
async for chunk in stream:
yield chunk
finally:
try:
model_info = ModelService.get(model)
effort = (
EffortType.HIGH
if (model_info and model_info.cost.value == "high")
else EffortType.LOW
)
await token_mgr.consume(token, effort)
logger.debug(
f"Video stream completed, recorded usage for token {token[:10]}... (effort={effort.value})"
)
except Exception as e:
logger.warning(f"Failed to record video stream usage: {e}")
@staticmethod
async def completions(
model: str,
@@ -375,11 +415,11 @@ class VideoService:
aspect_ratio: str = "3:2",
video_length: int = 6,
resolution: str = "SD",
preset: str = "normal"
preset: str = "normal",
):
"""
视频生成入口
Args:
model: 模型名称
messages: 消息列表
@@ -389,7 +429,7 @@ class VideoService:
video_length: 视频时长
resolution: 分辨率
preset: 预设模式
Returns:
AsyncGenerator (流式) 或 dict (非流式)
"""
@@ -404,35 +444,35 @@ class VideoService:
raise AppException(
message="Internal service error obtaining token",
error_type=ErrorType.SERVER.value,
code="internal_error"
code="internal_error",
)
if not token:
raise AppException(
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429
)
status_code=429,
)
# 解析参数
think = None
if thinking == "enabled":
think = True
elif thinking == "disabled":
think = False
is_stream = stream if stream is not None else get_config("grok.stream", True)
# 提取内容
from app.services.grok.chat import MessageExtractor
from app.services.grok.assets import UploadService
try:
prompt, attachments = MessageExtractor.extract(messages, is_video=True)
except ValueError as e:
raise ValidationException(str(e))
# 处理图片附件
image_url = None
if attachments:
@@ -447,27 +487,50 @@ class VideoService:
break # 视频模型只使用第一张图片
finally:
await upload_service.close()
# 生成视频
service = VideoService()
# 图片转视频
if image_url:
response = await service.generate_from_image(
token, prompt, image_url,
aspect_ratio, video_length, resolution, stream, preset
token,
prompt,
image_url,
aspect_ratio,
video_length,
resolution,
stream,
preset,
)
else:
response = await service.generate(
token, prompt,
aspect_ratio, video_length, resolution, stream, preset
token, prompt, aspect_ratio, video_length, resolution, stream, preset
)
# 处理响应
if is_stream:
return VideoStreamProcessor(model, token, think).process(response)
processor = VideoStreamProcessor(model, token, think)
return VideoService._wrap_stream(
processor.process(response), token_mgr, token, model
)
else:
return await VideoCollectProcessor(model, token).process(response)
result = await VideoCollectProcessor(model, token).process(response)
# 非流式:处理完成后立即记录使用
try:
model_info = ModelService.get(model)
effort = (
EffortType.HIGH
if (model_info and model_info.cost.value == "high")
else EffortType.LOW
)
await token_mgr.consume(token, effort)
logger.debug(
f"Video completed, recorded usage for token {token[:10]}... (effort={effort.value})"
)
except Exception as e:
logger.warning(f"Failed to record video usage: {e}")
return result
__all__ = ["VideoService"]
+112
View File
@@ -0,0 +1,112 @@
"""
NSFW (Unhinged) 模式服务
使用 gRPC-Web 协议开启账号的 NSFW 功能。
"""
from dataclasses import dataclass
from typing import Optional
from curl_cffi.requests import AsyncSession
from app.core.config import get_config
from app.core.logger import logger
from app.services.grok.grpc_web import (
encode_grpc_web_payload,
parse_grpc_web_response,
get_grpc_status,
)
NSFW_API = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls"
BROWSER = "chrome136"
TIMEOUT = 30
@dataclass
class NSFWResult:
"""NSFW 操作结果"""
success: bool
http_status: int
grpc_status: Optional[int] = None
grpc_message: Optional[str] = None
error: Optional[str] = None
class NSFWService:
"""NSFW 模式服务"""
def __init__(self, proxy: str = None):
self.proxy = proxy or get_config("grok.base_proxy_url", "")
def _build_headers(self, token: str) -> dict:
"""构造 gRPC-Web 请求头"""
token = token[4:] if token.startswith("sso=") else token
cf = get_config("grok.cf_clearance", "")
cookie = f"sso={token}; sso-rw={token}"
if cf:
cookie += f"; cf_clearance={cf}"
return {
"accept": "*/*",
"content-type": "application/grpc-web+proto",
"origin": "https://grok.com",
"referer": "https://grok.com/",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"x-grpc-web": "1",
"x-user-agent": "connect-es/2.1.1",
"cookie": cookie,
}
@staticmethod
def _build_payload() -> bytes:
"""构造请求 payload"""
# protobuf: enable_unhinged=true (field1=1, field2=1)
protobuf = bytes([0x08, 0x01, 0x10, 0x01])
return encode_grpc_web_payload(protobuf)
async def enable(self, token: str) -> NSFWResult:
"""为单个 token 开启 NSFW 模式"""
headers = self._build_headers(token)
payload = self._build_payload()
proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None
try:
async with AsyncSession(impersonate=BROWSER) as session:
response = await session.post(
NSFW_API,
data=payload,
headers=headers,
timeout=TIMEOUT,
proxies=proxies,
)
if response.status_code != 200:
return NSFWResult(
success=False,
http_status=response.status_code,
error=f"HTTP {response.status_code}",
)
# 解析 gRPC-Web 响应
content_type = response.headers.get("content-type")
_, trailers = parse_grpc_web_response(
response.content, content_type=content_type
)
grpc_status = get_grpc_status(trailers)
return NSFWResult(
success=grpc_status.ok,
http_status=response.status_code,
grpc_status=grpc_status.code,
grpc_message=grpc_status.message or None,
)
except Exception as e:
logger.error(f"NSFW enable failed: {e}")
return NSFWResult(success=False, http_status=0, error=str(e)[:100])
__all__ = ["NSFWService", "NSFWResult"]
+41 -33
View File
@@ -22,6 +22,7 @@ FAIL_THRESHOLD = 5
class TokenStatus(str, Enum):
"""Token 状态"""
ACTIVE = "active"
DISABLED = "disabled"
EXPIRED = "expired"
@@ -30,7 +31,8 @@ class TokenStatus(str, Enum):
class EffortType(str, Enum):
"""请求消耗类型"""
LOW = "low" # 扣 1
LOW = "low" # 扣 1
HIGH = "high" # 扣 4
@@ -42,122 +44,127 @@ EFFORT_COST = {
class TokenInfo(BaseModel):
"""Token 信息"""
token: str
status: TokenStatus = TokenStatus.ACTIVE
quota: int = DEFAULT_QUOTA
# 统计
created_at: int = Field(default_factory=lambda: int(datetime.now().timestamp() * 1000))
created_at: int = Field(
default_factory=lambda: int(datetime.now().timestamp() * 1000)
)
last_used_at: Optional[int] = None
use_count: int = 0
# 失败追踪
fail_count: int = 0
last_fail_at: Optional[int] = None
last_fail_reason: Optional[str] = None
# 冷却管理
last_sync_at: Optional[int] = None # 上次同步时间
# 扩展
tags: List[str] = Field(default_factory=list)
note: str = ""
last_asset_clear_at: Optional[int] = None
def is_available(self) -> bool:
"""检查是否可用(状态正常且配额 > 0)"""
return self.status == TokenStatus.ACTIVE and self.quota > 0
def consume(self, effort: EffortType = EffortType.LOW) -> int:
"""
消耗配额
Args:
effort: LOW 扣 1,HIGH 扣 4
effort: LOW 扣 1 配额并计 1 次,HIGH 扣 4 配额并计 4 次
Returns:
实际扣除的配额
"""
cost = EFFORT_COST[effort]
actual_cost = min(cost, self.quota)
self.last_used_at = int(datetime.now().timestamp() * 1000)
self.use_count += 1
self.quota = max(0, self.quota - cost)
self.use_count += actual_cost # 使用 actual_cost 避免配额不足时过度计数
self.quota = max(0, self.quota - actual_cost)
# 成功消耗后清空失败计数
self.fail_count = 0
self.last_fail_reason = None
if self.quota == 0:
self.status = TokenStatus.COOLING
elif self.status in [TokenStatus.COOLING, TokenStatus.EXPIRED]:
self.status = TokenStatus.ACTIVE
return actual_cost
def update_quota(self, new_quota: int):
"""
更新配额(用于 API 同步)
Args:
new_quota: 新的配额值
"""
self.quota = max(0, new_quota)
if self.quota == 0:
self.status = TokenStatus.COOLING
elif self.quota > 0 and self.status in [TokenStatus.COOLING, TokenStatus.EXPIRED]:
elif self.quota > 0 and self.status in [
TokenStatus.COOLING,
TokenStatus.EXPIRED,
]:
self.status = TokenStatus.ACTIVE
def reset(self):
"""重置配额到默认值"""
self.quota = DEFAULT_QUOTA
self.status = TokenStatus.ACTIVE
self.fail_count = 0
self.last_fail_reason = None
def record_fail(self, status_code: int = 401, reason: str = ""):
"""记录失败,达到阈值后自动标记为 expired"""
# 仅 401 错误才计入失败
if status_code != 401:
return
self.fail_count += 1
self.last_fail_at = int(datetime.now().timestamp() * 1000)
self.last_fail_reason = reason
if self.fail_count >= FAIL_THRESHOLD:
self.status = TokenStatus.EXPIRED
def record_success(self, is_usage: bool = True):
"""记录成功,清空失败计数并根据配额更新状态"""
self.fail_count = 0
self.last_fail_at = None
self.last_fail_reason = None
if is_usage:
self.use_count += 1
self.last_used_at = int(datetime.now().timestamp() * 1000)
if self.quota == 0:
self.status = TokenStatus.COOLING
else:
self.status = TokenStatus.ACTIVE
def need_refresh(self, interval_hours: int = 8) -> bool:
"""检查是否需要刷新配额"""
if self.status != TokenStatus.COOLING:
return False
if self.last_sync_at is None:
return True
now = int(datetime.now().timestamp() * 1000)
interval_ms = interval_hours * 3600 * 1000
return (now - self.last_sync_at) >= interval_ms
def mark_synced(self):
"""标记已同步"""
self.last_sync_at = int(datetime.now().timestamp() * 1000)
@@ -165,6 +172,7 @@ class TokenInfo(BaseModel):
class TokenPoolStats(BaseModel):
"""Token 池统计"""
total: int = 0
active: int = 0
disabled: int = 0
+39
View File
@@ -278,3 +278,42 @@
.confirm-dialog .modal-content {
padding: 0;
}
/* Tab Styles (Geist Design System) */
.tab-item {
position: relative;
padding: 12px 0;
margin-right: 20px;
font-size: 14px;
font-weight: 500;
color: var(--accents-5);
background: transparent;
border: none;
box-shadow: inset 0 -2px 0 0 transparent;
cursor: pointer;
transition: color 0.15s ease, box-shadow 0.15s ease;
white-space: nowrap;
}
.tab-item:hover {
color: var(--geist-foreground, #000);
}
.tab-item.active {
color: var(--geist-foreground, #000);
box-shadow: inset 0 -2px 0 0 var(--geist-foreground, #000);
}
.tab-item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Hide scrollbar for tabs on mobile */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
+25
View File
@@ -89,6 +89,27 @@
</div>
</div>
<!-- Tab Filter (with ARIA) -->
<div class="flex items-center gap-4 mb-4 border-b border-[var(--border)] overflow-x-auto hide-scrollbar"
role="tablist" aria-label="Token 状态筛选" id="status-tabs">
<button class="tab-item active" role="tab" aria-selected="true"
data-filter="all" onclick="filterByStatus('all')">
全部 <span class="badge" id="tab-count-all">0</span>
</button>
<button class="tab-item" role="tab" aria-selected="false"
data-filter="active" onclick="filterByStatus('active')">
正常 <span class="badge badge-green" id="tab-count-active">0</span>
</button>
<button class="tab-item" role="tab" aria-selected="false"
data-filter="cooling" onclick="filterByStatus('cooling')">
限流 <span class="badge badge-orange" id="tab-count-cooling">0</span>
</button>
<button class="tab-item" role="tab" aria-selected="false"
data-filter="expired" onclick="filterByStatus('expired')">
失效 <span class="badge badge-red" id="tab-count-expired">0</span>
</button>
</div>
<!-- Token Table -->
<div class="rounded-lg overflow-hidden bg-white mb-4 overflow-x-auto">
<table class="geist-table min-w-[800px]">
@@ -137,6 +158,10 @@
class="geist-button-outline text-xs px-3 gap-1 border-0 hover:bg-gray-100 justify-center">
刷新
</button>
<button id="btn-batch-nsfw" onclick="batchEnableNSFW()"
class="geist-button-outline text-xs px-3 gap-1 border-0 hover:bg-gray-100 justify-center">
NSFW
</button>
<button id="btn-batch-delete" onclick="batchDelete()" class="geist-button-danger text-xs px-3">
删除
</button>
+115 -6
View File
@@ -7,6 +7,7 @@ let batchQueue = [];
let batchTotal = 0;
let batchProcessed = 0;
let currentBatchAction = null;
let currentFilter = 'all';
const BATCH_SIZE = 50;
async function init() {
@@ -107,19 +108,27 @@ function renderTable() {
tbody.innerHTML = '';
loading.classList.add('hidden');
if (flatTokens.length === 0) {
// 更新 Tab 计数
updateTabCounts();
// 获取筛选后的列表
const filteredTokens = getFilteredTokens();
if (filteredTokens.length === 0) {
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
flatTokens.forEach((item, index) => {
filteredTokens.forEach((item) => {
// 获取原始索引用于操作
const originalIndex = flatTokens.indexOf(item);
const tr = document.createElement('tr');
// Checkbox (Center)
const tdCheck = document.createElement('td');
tdCheck.className = 'text-center';
tdCheck.innerHTML = `<input type="checkbox" class="checkbox" ${item._selected ? 'checked' : ''} onchange="toggleSelect(${index})">`;
tdCheck.innerHTML = `<input type="checkbox" class="checkbox" ${item._selected ? 'checked' : ''} onchange="toggleSelect(${originalIndex})">`;
// Token (Left)
const tdToken = document.createElement('td');
@@ -168,10 +177,10 @@ function renderTable() {
<button onclick="refreshStatus('${item.token}')" class="p-1 text-gray-400 hover:text-black rounded" title="刷新状态">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><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"></path></svg>
</button>
<button onclick="openEditModal(${index})" class="p-1 text-gray-400 hover:text-black rounded" title="编辑">
<button onclick="openEditModal(${originalIndex})" class="p-1 text-gray-400 hover:text-black rounded" title="编辑">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<button onclick="deleteToken(${index})" class="p-1 text-gray-400 hover:text-red-600 rounded" title="删除">
<button onclick="deleteToken(${originalIndex})" class="p-1 text-gray-400 hover:text-red-600 rounded" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><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"></path></svg>
</button>
</div>
@@ -195,7 +204,8 @@ function renderTable() {
function toggleSelectAll() {
const checkbox = document.getElementById('select-all');
const checked = checkbox.checked;
flatTokens.forEach(t => t._selected = checked);
// 只选择当前筛选后可见的 Token
getFilteredTokens().forEach(t => t._selected = checked);
renderTable();
}
@@ -734,6 +744,105 @@ function escapeHtml(text) {
.replace(/'/g, "&#039;");
}
// ========== Tab 筛选功能 ==========
function filterByStatus(status) {
currentFilter = status;
// 更新 Tab 样式和 ARIA
document.querySelectorAll('.tab-item').forEach(tab => {
const isActive = tab.dataset.filter === status;
tab.classList.toggle('active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
renderTable();
}
function getFilteredTokens() {
if (currentFilter === 'all') return flatTokens;
return flatTokens.filter(t => {
if (currentFilter === 'active') return t.status === 'active';
if (currentFilter === 'cooling') return t.status === 'cooling';
if (currentFilter === 'expired') return t.status !== 'active' && t.status !== 'cooling';
return true;
});
}
function updateTabCounts() {
const counts = {
all: flatTokens.length,
active: flatTokens.filter(t => t.status === 'active').length,
cooling: flatTokens.filter(t => t.status === 'cooling').length,
expired: flatTokens.filter(t => t.status !== 'active' && t.status !== 'cooling').length
};
Object.entries(counts).forEach(([key, count]) => {
const el = document.getElementById(`tab-count-${key}`);
if (el) el.textContent = count;
});
}
// ========== NSFW 批量开启 ==========
async function batchEnableNSFW() {
if (isBatchProcessing) {
showToast('当前有任务进行中', 'info');
return;
}
const selected = flatTokens.filter(t => t._selected);
const targetCount = selected.length || flatTokens.length;
const msg = selected.length === 0
? `是否对全部 ${targetCount} 个 Token 开启 NSFW 模式?`
: `是否为选中的 ${selected.length} 个 Token 开启 NSFW 模式?`;
const ok = await confirmAction(msg, { okText: '开启 NSFW' });
if (!ok) return;
// 禁用按钮
const btn = document.getElementById('btn-batch-nsfw');
if (btn) btn.disabled = true;
isBatchProcessing = true;
currentBatchAction = 'nsfw';
updateBatchProgress();
try {
const tokens = selected.length > 0 ? selected.map(t => t.token) : null;
const res = await fetch('/api/v1/admin/tokens/nsfw/enable', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify({ tokens })
});
const data = await res.json();
if (res.ok && data.status === 'success') {
const { ok: okCount, fail } = data.summary;
showToast(
`NSFW 开启完成:成功 ${okCount},失败 ${fail}`,
fail > 0 ? 'warning' : 'success'
);
} else {
showToast('开启失败: ' + (data.detail || '未知错误'), 'error');
}
} catch (e) {
showToast('请求错误: ' + e.message, 'error');
} finally {
isBatchProcessing = false;
currentBatchAction = null;
if (btn) btn.disabled = false;
updateBatchProgress();
setActionButtonsState();
}
}
window.onload = init;
+3
View File
@@ -35,3 +35,6 @@ media_max_concurrent = 50
usage_max_concurrent = 25
assets_delete_batch_size = 10
admin_assets_batch_size = 10
nsfw_max_concurrent = 10
admin_nsfw_batch_size = 50
nsfw_max_tokens = 1000