style: 修复 ruff 代码规范问题

- 修复裸 except 为 except Exception
- 修复单行多语句 (E701)
- 移除未使用的导入 (F401)
- 添加 noqa 注释忽略 main.py 的 E402 (设计决策)
- 代码格式化
This commit is contained in:
piexian committed 2026-02-04 06:40:29 +08:00
1 parent 82a0490536
commit d25d2eae18
24 files changed
+1028 -758

No files matched your search

+13 -3
View File
@@ -78,7 +78,9 @@ async def stream_batch(task_id: str, request: Request):
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/api/v1/admin/batch/{task_id}/cancel", dependencies=[Depends(verify_api_key)])
@router.post(
"/api/v1/admin/batch/{task_id}/cancel", dependencies=[Depends(verify_api_key)]
)
async def cancel_batch(task_id: str):
task = get_task(task_id)
if not task:
@@ -221,7 +223,10 @@ async def refresh_tokens_api(data: dict):
)
raw_results = await run_in_batches(
unique_tokens, _refresh_one, max_concurrent=max_concurrent, batch_size=batch_size
unique_tokens,
_refresh_one,
max_concurrent=max_concurrent,
batch_size=batch_size,
)
results = {}
@@ -283,6 +288,7 @@ async def refresh_tokens_api_async(data: dict):
async def _run():
try:
async def _refresh_one(t: str):
return await mgr.sync_usage(
t, "grok-3", consume_on_fail=False, is_usage=False
@@ -444,7 +450,9 @@ async def enable_nsfw_api(data: dict):
# 添加截断提示
if truncated:
response["warning"] = f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
response["warning"] = (
f"数量超出限制,仅处理前 {max_tokens} 个(共 {original_count} 个)"
)
return response
@@ -506,6 +514,7 @@ async def enable_nsfw_api_async(data: dict):
async def _run():
try:
async def _enable(token: str):
result = await nsfw_service.enable(token)
if result.success:
@@ -1162,6 +1171,7 @@ async def clear_online_cache_api_async(data: dict):
async def _run():
delete_service = DeleteService()
try:
async def _clear_one(t: str):
try:
result = await delete_service.delete_all(t)
+50 -41
View File
@@ -24,6 +24,7 @@ USER_CONTENT_TYPES = ["text", "image_url", "input_audio", "file"]
class MessageItem(BaseModel):
"""消息项"""
role: str
content: Union[str, List[Dict[str, Any]]]
tool_call_id: Optional[str] = None # tool 角色需要的字段
@@ -43,11 +44,14 @@ class MessageItem(BaseModel):
class VideoConfig(BaseModel):
"""视频生成配置"""
aspect_ratio: Optional[str] = Field("3:2", description="视频比例: 3:2, 16:9, 1:1 等")
aspect_ratio: Optional[str] = Field(
"3:2", description="视频比例: 3:2, 16:9, 1:1 等"
)
video_length: Optional[int] = Field(6, description="视频时长(秒): 5-15")
resolution: Optional[str] = Field("SD", description="视频分辨率: SD, HD")
preset: Optional[str] = Field("custom", description="风格预设: fun, normal, spicy")
@field_validator("aspect_ratio")
@classmethod
def validate_aspect_ratio(cls, v):
@@ -56,10 +60,10 @@ class VideoConfig(BaseModel):
raise ValidationException(
message=f"aspect_ratio must be one of {allowed}",
param="video_config.aspect_ratio",
code="invalid_aspect_ratio"
code="invalid_aspect_ratio",
)
return v
@field_validator("video_length")
@classmethod
def validate_video_length(cls, v):
@@ -68,7 +72,7 @@ class VideoConfig(BaseModel):
raise ValidationException(
message="video_length must be between 5 and 15 seconds",
param="video_config.video_length",
code="invalid_video_length"
code="invalid_video_length",
)
return v
@@ -80,10 +84,10 @@ class VideoConfig(BaseModel):
raise ValidationException(
message=f"resolution must be one of {allowed}",
param="video_config.resolution",
code="invalid_resolution"
code="invalid_resolution",
)
return v
@field_validator("preset")
@classmethod
def validate_preset(cls, v):
@@ -92,16 +96,17 @@ class VideoConfig(BaseModel):
return "custom"
allowed = ["fun", "normal", "spicy", "custom"]
if v not in allowed:
raise ValidationException(
raise ValidationException(
message=f"preset must be one of {allowed}",
param="video_config.preset",
code="invalid_preset"
)
code="invalid_preset",
)
return v
class ChatCompletionRequest(BaseModel):
"""Chat Completions 请求"""
model: str = Field(..., description="模型名称")
messages: List[MessageItem] = Field(..., description="消息数组")
stream: Optional[bool] = Field(None, description="是否流式输出")
@@ -132,9 +137,7 @@ class ChatCompletionRequest(BaseModel):
f"Invalid stream value type '{type(v).__name__}'. Must be a boolean or string."
)
model_config = {
"extra": "ignore"
}
model_config = {"extra": "ignore"}
def validate_request(request: ChatCompletionRequest):
@@ -144,65 +147,69 @@ def validate_request(request: ChatCompletionRequest):
raise ValidationException(
message=f"The model `{request.model}` does not exist or you do not have access to it.",
param="model",
code="model_not_found"
code="model_not_found",
)
# 验证消息
for idx, msg in enumerate(request.messages):
content = msg.content
# 字符串内容
if isinstance(content, str):
if not content.strip():
raise ValidationException(
message="Message content cannot be empty",
param=f"messages.{idx}.content",
code="empty_content"
code="empty_content",
)
# 列表内容
elif isinstance(content, list):
if not content:
raise ValidationException(
message="Message content cannot be an empty array",
param=f"messages.{idx}.content",
code="empty_content"
code="empty_content",
)
for block_idx, block in enumerate(content):
# 检查空对象
if not block:
raise ValidationException(
message="Content block cannot be empty",
param=f"messages.{idx}.content.{block_idx}",
code="empty_block"
code="empty_block",
)
# 检查 type 字段
if "type" not in block:
raise ValidationException(
message="Content block must have a 'type' field",
param=f"messages.{idx}.content.{block_idx}",
code="missing_type"
code="missing_type",
)
block_type = block.get("type")
# 检查 type 空值
if not block_type or not isinstance(block_type, str) or not block_type.strip():
if (
not block_type
or not isinstance(block_type, str)
or not block_type.strip()
):
raise ValidationException(
message="Content block 'type' cannot be empty",
param=f"messages.{idx}.content.{block_idx}.type",
code="empty_type"
code="empty_type",
)
# 验证 type 有效性
if msg.role == "user":
if block_type not in USER_CONTENT_TYPES:
raise ValidationException(
message=f"Invalid content block type: '{block_type}'",
param=f"messages.{idx}.content.{block_idx}.type",
code="invalid_type"
code="invalid_type",
)
elif msg.role in ("tool", "function"):
# tool/function 角色只支持 text 类型,但内容可以是 JSON 字符串
@@ -210,15 +217,15 @@ def validate_request(request: ChatCompletionRequest):
raise ValidationException(
message=f"The `{msg.role}` role only supports 'text' type, got '{block_type}'",
param=f"messages.{idx}.content.{block_idx}.type",
code="invalid_type"
code="invalid_type",
)
elif block_type != "text":
raise ValidationException(
message=f"The `{msg.role}` role only supports 'text' type, got '{block_type}'",
param=f"messages.{idx}.content.{block_idx}.type",
code="invalid_type"
code="invalid_type",
)
# 验证字段是否存在 & 非空
if block_type == "text":
text = block.get("text", "")
@@ -226,15 +233,17 @@ def validate_request(request: ChatCompletionRequest):
raise ValidationException(
message="Text content cannot be empty",
param=f"messages.{idx}.content.{block_idx}.text",
code="empty_text"
code="empty_text",
)
elif block_type == "image_url":
image_url = block.get("image_url")
if not image_url or not (isinstance(image_url, dict) and image_url.get("url")):
if not image_url or not (
isinstance(image_url, dict) and image_url.get("url")
):
raise ValidationException(
message="image_url must have a 'url' field",
param=f"messages.{idx}.content.{block_idx}.image_url",
code="missing_url"
code="missing_url",
)
@@ -252,10 +261,10 @@ async def chat_completions(request: ChatCompletionRequest):
model_info = ModelService.get(request.model)
if model_info and model_info.is_video:
from app.services.grok.media import VideoService
# 提取视频配置 (默认值在 Pydantic 模型中处理)
v_conf = request.video_config or VideoConfig()
result = await VideoService.completions(
model=request.model,
messages=[msg.model_dump() for msg in request.messages],
@@ -264,23 +273,23 @@ async def chat_completions(request: ChatCompletionRequest):
aspect_ratio=v_conf.aspect_ratio,
video_length=v_conf.video_length,
resolution=v_conf.resolution,
preset=v_conf.preset
preset=v_conf.preset,
)
else:
result = await ChatService.completions(
model=request.model,
messages=[msg.model_dump() for msg in request.messages],
stream=request.stream,
thinking=request.thinking
thinking=request.thinking,
)
if isinstance(result, dict):
return JSONResponse(content=result)
else:
return StreamingResponse(
result,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
+9 -13
View File
@@ -24,9 +24,9 @@ async def get_image(filename: str):
"""
if "/" in filename:
filename = filename.replace("/", "-")
file_path = IMAGE_DIR / filename
if await aiofiles.os.path.exists(file_path):
if await aiofiles.os.path.isfile(file_path):
content_type = "image/jpeg"
@@ -34,14 +34,12 @@ async def get_image(filename: str):
content_type = "image/png"
elif file_path.suffix.lower() == ".webp":
content_type = "image/webp"
# 增加缓存头,支持高并发场景下的浏览器/CDN缓存
return FileResponse(
file_path,
file_path,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000, immutable"
}
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
logger.warning(f"Image not found: {filename}")
@@ -55,17 +53,15 @@ async def get_video(filename: str):
"""
if "/" in filename:
filename = filename.replace("/", "-")
file_path = VIDEO_DIR / filename
if await aiofiles.os.path.exists(file_path):
if await aiofiles.os.path.isfile(file_path):
return FileResponse(
file_path,
file_path,
media_type="video/mp4",
headers={
"Cache-Control": "public, max-age=31536000, immutable"
}
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
logger.warning(f"Video not found: {filename}")
+1 -1
View File
@@ -1,3 +1,3 @@
"""
TODO:Video Generation API 路由
"""
"""
+11 -10
View File
@@ -1,8 +1,9 @@
"""
API 认证模块
"""
from typing import Optional
from fastapi import Depends, HTTPException, status, Security
from fastapi import HTTPException, status, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.core.config import get_config
@@ -11,48 +12,48 @@ from app.core.config import get_config
security = HTTPBearer(
auto_error=False,
scheme_name="API Key",
description="Enter your API Key in the format: Bearer <key>"
description="Enter your API Key in the format: Bearer <key>",
)
async def verify_api_key(
auth: Optional[HTTPAuthorizationCredentials] = Security(security)
auth: Optional[HTTPAuthorizationCredentials] = Security(security),
) -> Optional[str]:
"""
验证 Bearer Token
如果 config.toml 中未配置 api_key,则跳过验证。
如果配置了 api_key,则必须提供正确的 Authorization: Bearer <key>。
"""
api_key = get_config("app.api_key", "")
# 如果未配置 API Key,直接放行
if not api_key:
return None
if not auth:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
if auth.credentials != api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return auth.credentials
async def verify_app_key(
auth: Optional[HTTPAuthorizationCredentials] = Security(security)
auth: Optional[HTTPAuthorizationCredentials] = Security(security),
) -> Optional[str]:
"""
验证后台登录密钥(app_key)。
如果未配置 app_key,则跳过验证。
"""
app_key = get_config("app.app_key", "")
+3 -2
View File
@@ -54,7 +54,9 @@ class BatchTask:
# Drop if queue is full or closed
pass
def record(self, ok: bool, *, item: Any = None, detail: Any = None, error: str = "") -> None:
def record(
self, ok: bool, *, item: Any = None, detail: Any = None, error: str = ""
) -> None:
self.processed += 1
if ok:
self.ok += 1
@@ -148,4 +150,3 @@ def delete_task(task_id: str) -> None:
async def expire_task(task_id: str, delay: int = 300) -> None:
await asyncio.sleep(delay)
delete_task(task_id)
+7 -6
View File
@@ -46,10 +46,10 @@ def _load_defaults() -> Dict[str, Any]:
class Config:
"""配置管理器"""
_instance = None
_config = {}
def __init__(self):
self._config = {}
self._defaults = {}
@@ -65,13 +65,13 @@ class Config:
"""显式加载配置"""
try:
from app.core.storage import get_storage, LocalStorage
self._ensure_defaults()
storage = get_storage()
config_data = await storage.load_config()
from_remote = True
# 从本地 data/config.toml 初始化后端
if config_data is None:
local_storage = LocalStorage()
@@ -104,7 +104,7 @@ class Config:
def get(self, key: str, default: Any = None) -> Any:
"""
获取配置值
Args:
key: 配置键,格式 "section.key"
default: 默认值
@@ -115,12 +115,13 @@ class Config:
return self._config.get(section, {}).get(attr, default)
except (ValueError, AttributeError):
return default
return self._config.get(key, default)
async def update(self, new_config: dict):
"""更新配置"""
from app.core.storage import get_storage
storage = get_storage()
async with storage.acquire_lock("config_save", timeout=10):
self._ensure_defaults()
+40 -37
View File
@@ -2,7 +2,7 @@
全局异常处理 - OpenAI 兼容错误格式
"""
from typing import Any, Optional
from typing import Any
from enum import Enum
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
@@ -13,8 +13,10 @@ from app.core.logger import logger
# ============= 错误类型 =============
class ErrorType(str, Enum):
"""OpenAI 错误类型"""
INVALID_REQUEST = "invalid_request_error"
AUTHENTICATION = "authentication_error"
PERMISSION = "permission_error"
@@ -26,35 +28,32 @@ class ErrorType(str, Enum):
# ============= 辅助函数 =============
def error_response(
message: str,
error_type: str = ErrorType.INVALID_REQUEST.value,
param: str = None,
code: str = None
code: str = None,
) -> dict:
"""构建 OpenAI 错误响应"""
return {
"error": {
"message": message,
"type": error_type,
"param": param,
"code": code
}
"error": {"message": message, "type": error_type, "param": param, "code": code}
}
# ============= 异常类 =============
class AppException(Exception):
"""应用基础异常"""
def __init__(
self,
message: str,
error_type: str = ErrorType.SERVER.value,
code: str = None,
param: str = None,
status_code: int = 500
status_code: int = 500,
):
self.message = message
self.error_type = error_type
@@ -66,56 +65,57 @@ class AppException(Exception):
class ValidationException(AppException):
"""验证错误"""
def __init__(self, message: str, param: str = None, code: str = None):
super().__init__(
message=message,
error_type=ErrorType.INVALID_REQUEST.value,
code=code or "invalid_value",
param=param,
status_code=400
status_code=400,
)
class AuthenticationException(AppException):
"""认证错误"""
def __init__(self, message: str = "Invalid API key"):
super().__init__(
message=message,
error_type=ErrorType.AUTHENTICATION.value,
code="invalid_api_key",
status_code=401
status_code=401,
)
class UpstreamException(AppException):
"""上游服务错误"""
def __init__(self, message: str, details: Any = None):
super().__init__(
message=message,
error_type=ErrorType.SERVER.value,
code="upstream_error",
status_code=502
status_code=502,
)
self.details = details
# ============= 异常处理器 =============
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
"""处理应用异常"""
logger.warning(f"AppException: {exc.error_type} - {exc.message}")
return JSONResponse(
status_code=exc.status_code,
content=error_response(
message=exc.message,
error_type=exc.error_type,
param=exc.param,
code=exc.code
)
code=exc.code,
),
)
@@ -129,7 +129,7 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
429: ErrorType.RATE_LIMIT.value,
}
error_type = type_map.get(exc.status_code, ErrorType.SERVER.value)
# 默认 code 映射
code_map = {
401: "invalid_api_key",
@@ -138,69 +138,72 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
429: "rate_limit_exceeded",
}
code = code_map.get(exc.status_code, None)
logger.warning(f"HTTPException: {exc.status_code} - {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content=error_response(
message=str(exc.detail),
error_type=error_type,
code=code
)
message=str(exc.detail), error_type=error_type, code=code
),
)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""处理验证错误"""
errors = exc.errors()
if errors:
first = errors[0]
loc = first.get("loc", [])
msg = first.get("msg", "Invalid request")
code = first.get("type", "invalid_value")
# JSON 解析错误
if code == "json_invalid" or "JSON" in msg:
message = "Invalid JSON in request body. Please check for trailing commas or syntax errors."
param = "body"
else:
param_parts = [str(x) for x in loc if not (isinstance(x, int) or str(x).isdigit())]
param_parts = [
str(x) for x in loc if not (isinstance(x, int) or str(x).isdigit())
]
param = ".".join(param_parts) if param_parts else None
message = msg
else:
param, message, code = None, "Invalid request", "invalid_value"
logger.warning(f"ValidationError: {param} - {message}")
return JSONResponse(
status_code=400,
content=error_response(
message=message,
error_type=ErrorType.INVALID_REQUEST.value,
param=param,
code=code
)
code=code,
),
)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""处理未捕获异常"""
logger.exception(f"Unhandled: {type(exc).__name__}: {str(exc)}")
return JSONResponse(
status_code=500,
content=error_response(
message="Internal server error",
error_type=ErrorType.SERVER.value,
code="internal_error"
)
code="internal_error",
),
)
# ============= 注册 =============
def register_exception_handlers(app):
"""注册异常处理器"""
app.add_exception_handler(AppException, app_exception_handler)
+17 -13
View File
@@ -20,42 +20,46 @@ def _format_json(record) -> str:
tz = record["time"].strftime("%z")
if tz:
time_str += tz[:3] + ":" + tz[3:]
log_entry = {
"time": time_str,
"level": record["level"].name.lower(),
"msg": record["message"],
"caller": f"{record['file'].name}:{record['line']}",
}
# trace 上下文
extra = record["extra"]
if extra.get("traceID"):
log_entry["traceID"] = extra["traceID"]
if extra.get("spanID"):
log_entry["spanID"] = extra["spanID"]
# 其他 extra 字段
for key, value in extra.items():
if key not in ("traceID", "spanID") and not key.startswith("_"):
log_entry[key] = value
# 错误及以上级别添加堆栈跟踪
if record["level"].no >= 40 and record["exception"]:
log_entry["stacktrace"] = "".join(traceback.format_exception(
record["exception"].type,
record["exception"].value,
record["exception"].traceback
))
log_entry["stacktrace"] = "".join(
traceback.format_exception(
record["exception"].type,
record["exception"].value,
record["exception"].traceback,
)
)
return json.dumps(log_entry, ensure_ascii=False)
def _make_json_sink(output):
"""创建 JSON sink"""
def sink(message):
json_str = _format_json(message.record)
print(json_str, file=output, flush=True)
return sink
@@ -75,7 +79,7 @@ def setup_logging(
):
"""设置日志配置"""
logger.remove()
# 控制台输出
if json_console:
logger.add(
@@ -91,7 +95,7 @@ def setup_logging(
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{file.name}:{line}</cyan> - <level>{message}</level>",
colorize=True,
)
# 文件输出
if file_logging:
logger.add(
@@ -100,7 +104,7 @@ def setup_logging(
format="{message}",
enqueue=True,
)
return logger
+15 -15
View File
@@ -9,39 +9,39 @@ import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.types import ASGIApp
from app.core.logger import logger
class ResponseLoggerMiddleware(BaseHTTPMiddleware):
"""
请求日志/响应追踪中间件
Request Logging and Response Tracking Middleware
"""
async def dispatch(self, request: Request, call_next):
# 生成请求 ID
trace_id = str(uuid.uuid4())
request.state.trace_id = trace_id
start_time = time.time()
# 记录请求信息
logger.info(
f"Request: {request.method} {request.url.path}",
extra={
"traceID": trace_id,
"method": request.method,
"path": request.url.path
}
"path": request.url.path,
},
)
try:
response = await call_next(request)
# 计算耗时
duration = (time.time() - start_time) * 1000
# 记录响应信息
logger.info(
f"Response: {request.method} {request.url.path} - {response.status_code} ({duration:.2f}ms)",
@@ -50,12 +50,12 @@ class ResponseLoggerMiddleware(BaseHTTPMiddleware):
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round(duration, 2)
}
"duration_ms": round(duration, 2),
},
)
return response
except Exception as e:
duration = (time.time() - start_time) * 1000
logger.error(
@@ -65,7 +65,7 @@ class ResponseLoggerMiddleware(BaseHTTPMiddleware):
"method": request.method,
"path": request.url.path,
"duration_ms": round(duration, 2),
"error": str(e)
}
"error": str(e),
},
)
raise e
+190 -120
View File
@@ -12,13 +12,13 @@
import abc
import os
import asyncio
import os
import hashlib
import time
import tomllib
from typing import Any, Dict, Optional
from pathlib import Path
from enum import Enum
try:
import fcntl
except ImportError: # pragma: no cover - non-posix platforms
@@ -34,17 +34,22 @@ CONFIG_FILE = Path(__file__).parent.parent.parent / "data" / "config.toml"
TOKEN_FILE = Path(__file__).parent.parent.parent / "data" / "token.json"
LOCK_DIR = Path(__file__).parent.parent.parent / "data" / ".locks"
# JSON 序列化优化助手函数
def json_dumps(obj: Any) -> str:
return orjson.dumps(obj).decode("utf-8")
def json_loads(obj: str | bytes) -> Any:
return orjson.loads(obj)
class StorageError(Exception):
"""存储服务基础异常"""
pass
class BaseStorage(abc.ABC):
"""存储基类"""
@@ -72,13 +77,13 @@ class BaseStorage(abc.ABC):
async def close(self):
"""关闭资源"""
pass
@asynccontextmanager
async def acquire_lock(self, name: str, timeout: int = 10):
"""
获取锁 (互斥访问)
用于读写操作的临界区保护
Args:
name: 锁名称
timeout: 超时时间 (秒)
@@ -98,10 +103,10 @@ class LocalStorage(BaseStorage):
- 使用 asyncio.Lock 进行进程内并发控制
- 如果需要多进程安全,需要系统级文件锁 (fcntl)
"""
def __init__(self):
self._lock = asyncio.Lock()
@asynccontextmanager
async def acquire_lock(self, name: str, timeout: int = 10):
if fcntl is None:
@@ -163,7 +168,8 @@ class LocalStorage(BaseStorage):
try:
lines = []
for section, items in data.items():
if not isinstance(items, dict): continue
if not isinstance(items, dict):
continue
lines.append(f"[{section}]")
for key, val in items.items():
if isinstance(val, bool):
@@ -179,9 +185,9 @@ class LocalStorage(BaseStorage):
val_str = f'"{str(val)}"'
lines.append(f"{key} = {val_str}")
lines.append("")
content = "\n".join(lines)
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(CONFIG_FILE, "w", encoding="utf-8") as f:
await f.write(content)
@@ -203,15 +209,15 @@ class LocalStorage(BaseStorage):
async def save_tokens(self, data: Dict[str, Any]):
try:
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
temp_path = TOKEN_FILE.with_suffix('.tmp')
temp_path = TOKEN_FILE.with_suffix(".tmp")
# 原子写操作: 写入临时文件 -> 重命名
async with aiofiles.open(temp_path, "wb") as f:
await f.write(orjson.dumps(data, option=orjson.OPT_INDENT_2))
# 使用 os.replace 保证原子性
os.replace(temp_path, TOKEN_FILE)
except Exception as e:
logger.error(f"LocalStorage: 保存 Token 失败: {e}")
raise StorageError(f"保存 Token 失败: {e}")
@@ -227,25 +233,22 @@ class RedisStorage(BaseStorage):
- 支持分布式锁 (redis.lock)
- 扁平化数据结构优化性能
"""
def __init__(self, url: str):
try:
from redis import asyncio as aioredis
from redis.asyncio.lock import Lock
except ImportError:
raise ImportError("需要安装 redis 包: pip install redis")
# 显式配置连接池
# 使用 decode_responses=True 简化字符串处理,但在处理复杂对象时使用 orjson
self.redis = aioredis.from_url(
url,
decode_responses=True,
health_check_interval=30
url, decode_responses=True, health_check_interval=30
)
self.config_key = "grok2api:config" # Hash: section.key -> value_json
self.key_pools = "grok2api:pools" # Set: pool_names
self.prefix_pool_set = "grok2api:pool:" # Set: pool -> token_ids
self.prefix_token_hash = "grok2api:token:"# Hash: token_id -> token_data
self.config_key = "grok2api:config" # Hash: section.key -> value_json
self.key_pools = "grok2api:pools" # Set: pool_names
self.prefix_pool_set = "grok2api:pool:" # Set: pool -> token_ids
self.prefix_token_hash = "grok2api:token:" # Hash: token_id -> token_data
self.lock_prefix = "grok2api:lock:"
@asynccontextmanager
@@ -279,17 +282,19 @@ class RedisStorage(BaseStorage):
raw_data = await self.redis.hgetall(self.config_key)
if not raw_data:
return None
config = {}
for composite_key, val_str in raw_data.items():
if "." not in composite_key: continue
if "." not in composite_key:
continue
section, key = composite_key.split(".", 1)
if section not in config: config[section] = {}
if section not in config:
config[section] = {}
try:
val = json_loads(val_str)
except:
except Exception:
val = val_str
config[section][key] = val
return config
@@ -299,15 +304,17 @@ class RedisStorage(BaseStorage):
async def save_config(self, data: Dict[str, Any]):
"""保存配置到 Redis Hash"""
if not data: return
if not data:
return
try:
mapping = {}
for section, items in data.items():
if not isinstance(items, dict): continue
if not isinstance(items, dict):
continue
for key, val in items.items():
composite_key = f"{section}.{key}"
mapping[composite_key] = json_dumps(val)
if mapping:
await self.redis.hset(self.config_key, mapping=mapping)
except Exception as e:
@@ -318,24 +325,25 @@ class RedisStorage(BaseStorage):
"""加载所有 Token"""
try:
pool_names = await self.redis.smembers(self.key_pools)
if not pool_names: return None
if not pool_names:
return None
pools = {}
async with self.redis.pipeline() as pipe:
for pool_name in pool_names:
# 获取该池下所有 Token ID
pipe.smembers(f"{self.prefix_pool_set}{pool_name}")
pool_tokens_res = await pipe.execute()
# 收集所有 Token ID 以便批量查询
all_token_ids = []
pool_map = {} # pool_name -> list[token_id]
pool_map = {} # pool_name -> list[token_id]
for i, pool_name in enumerate(pool_names):
tids = list(pool_tokens_res[i])
pool_map[pool_name] = tids
all_token_ids.extend(tids)
if not all_token_ids:
return {name: [] for name in pool_names}
@@ -344,24 +352,37 @@ class RedisStorage(BaseStorage):
for tid in all_token_ids:
pipe.hgetall(f"{self.prefix_token_hash}{tid}")
token_data_list = await pipe.execute()
# 重组数据结构
token_lookup = {}
for i, tid in enumerate(all_token_ids):
t_data = token_data_list[i]
if not t_data: continue
if not t_data:
continue
# 恢复 tags (JSON -> List)
if "tags" in t_data:
try: t_data["tags"] = json_loads(t_data["tags"])
except: t_data["tags"] = []
try:
t_data["tags"] = json_loads(t_data["tags"])
except Exception:
t_data["tags"] = []
# 类型转换 (Redis 返回全 string)
for int_field in ["quota", "created_at", "use_count", "fail_count", "last_used_at", "last_fail_at", "last_sync_at"]:
for int_field in [
"quota",
"created_at",
"use_count",
"fail_count",
"last_used_at",
"last_fail_at",
"last_sync_at",
]:
if t_data.get(int_field) and t_data[int_field] != "None":
try: t_data[int_field] = int(t_data[int_field])
except: pass
try:
t_data[int_field] = int(t_data[int_field])
except Exception:
pass
token_lookup[tid] = t_data
# 按 Pool 分组返回
@@ -370,9 +391,9 @@ class RedisStorage(BaseStorage):
for tid in pool_map[pool_name]:
if tid in token_lookup:
pools[pool_name].append(token_lookup[tid])
return pools
except Exception as e:
logger.error(f"RedisStorage: 加载 Token 失败: {e}")
return None
@@ -438,15 +459,19 @@ class RedisStorage(BaseStorage):
if "tags" in t_flat:
t_flat["tags"] = json_dumps(t_flat["tags"])
status = t_flat.get("status")
if isinstance(status, str) and status.startswith("TokenStatus."):
if isinstance(status, str) and status.startswith(
"TokenStatus."
):
t_flat["status"] = status.split(".", 1)[1].lower()
elif isinstance(status, Enum):
t_flat["status"] = status.value
t_flat = {k: str(v) for k, v in t_flat.items() if v is not None}
pipe.hset(f"{self.prefix_token_hash}{token_str}", mapping=t_flat)
pipe.hset(
f"{self.prefix_token_hash}{token_str}", mapping=t_flat
)
await pipe.execute()
except Exception as e:
logger.error(f"RedisStorage: 保存 Token 失败: {e}")
raise
@@ -455,7 +480,7 @@ class RedisStorage(BaseStorage):
try:
await self.redis.close()
except (RuntimeError, asyncio.CancelledError, Exception):
# 忽略关闭时的 Event loop is closed 错误
# 忽略关闭时的 Event loop is closed 错误
pass
@@ -469,13 +494,14 @@ class SQLStorage(BaseStorage):
def __init__(self, url: str):
try:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text, MetaData
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
except ImportError:
raise ImportError("需要安装 sqlalchemy 和 async 驱动: pip install sqlalchemy[asyncio]")
raise ImportError(
"需要安装 sqlalchemy 和 async 驱动: pip install sqlalchemy[asyncio]"
)
self.dialect = url.split(":", 1)[0].split("+", 1)[0].lower()
# 配置 robust 的连接池
self.engine = create_async_engine(
url,
@@ -483,55 +509,70 @@ class SQLStorage(BaseStorage):
pool_size=20,
max_overflow=10,
pool_recycle=3600,
pool_pre_ping=True
pool_pre_ping=True,
)
self.async_session = async_sessionmaker(self.engine, expire_on_commit=False)
self._initialized = False
self._initialized = False
async def _ensure_schema(self):
"""确保数据库表存在"""
if self._initialized: return
if self._initialized:
return
try:
async with self.engine.begin() as conn:
from sqlalchemy import text
# Tokens 表 (通用 SQL)
await conn.execute(text("""
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS tokens (
token VARCHAR(512) PRIMARY KEY,
pool_name VARCHAR(64) NOT NULL,
data TEXT,
updated_at BIGINT
)
"""))
""")
)
# 配置表
await conn.execute(text("""
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS app_config (
section VARCHAR(64) NOT NULL,
key_name VARCHAR(64) NOT NULL,
value TEXT,
PRIMARY KEY (section, key_name)
)
"""))
""")
)
# 索引
try:
await conn.execute(text("CREATE INDEX idx_tokens_pool ON tokens (pool_name)"))
await conn.execute(
text("CREATE INDEX idx_tokens_pool ON tokens (pool_name)")
)
except Exception:
pass
# 尝试兼容旧表结构
try:
if self.dialect in ("mysql", "mariadb"):
await conn.execute(text("ALTER TABLE tokens MODIFY token VARCHAR(512)"))
await conn.execute(
text("ALTER TABLE tokens MODIFY token VARCHAR(512)")
)
await conn.execute(text("ALTER TABLE tokens MODIFY data TEXT"))
elif self.dialect in ("postgres", "postgresql", "pgsql"):
await conn.execute(text("ALTER TABLE tokens ALTER COLUMN token TYPE VARCHAR(512)"))
await conn.execute(text("ALTER TABLE tokens ALTER COLUMN data TYPE TEXT"))
await conn.execute(
text(
"ALTER TABLE tokens ALTER COLUMN token TYPE VARCHAR(512)"
)
)
await conn.execute(
text("ALTER TABLE tokens ALTER COLUMN data TYPE TEXT")
)
except Exception:
pass
self._initialized = True
except Exception as e:
logger.error(f"SQLStorage: Schema 初始化失败: {e}")
@@ -541,12 +582,13 @@ class SQLStorage(BaseStorage):
async def acquire_lock(self, name: str, timeout: int = 10):
# SQL 分布式锁: MySQL GET_LOCK / PG advisory_lock
from sqlalchemy import text
lock_name = f"g2a:{hashlib.sha1(name.encode('utf-8')).hexdigest()[:24]}"
if self.dialect in ("mysql", "mariadb"):
async with self.async_session() as session:
res = await session.execute(
text("SELECT GET_LOCK(:name, :timeout)"),
{"name": lock_name, "timeout": timeout}
{"name": lock_name, "timeout": timeout},
)
got = res.scalar()
if got != 1:
@@ -555,18 +597,21 @@ class SQLStorage(BaseStorage):
yield
finally:
try:
await session.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name})
await session.execute(
text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name}
)
await session.commit()
except Exception:
pass
elif self.dialect in ("postgres", "postgresql", "pgsql"):
lock_key = int.from_bytes(hashlib.sha256(name.encode("utf-8")).digest()[:8], "big", signed=False)
lock_key = int.from_bytes(
hashlib.sha256(name.encode("utf-8")).digest()[:8], "big", signed=False
)
async with self.async_session() as session:
start = time.monotonic()
while True:
res = await session.execute(
text("SELECT pg_try_advisory_lock(:key)"),
{"key": lock_key}
text("SELECT pg_try_advisory_lock(:key)"), {"key": lock_key}
)
if res.scalar():
break
@@ -577,7 +622,9 @@ class SQLStorage(BaseStorage):
yield
finally:
try:
await session.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": lock_key})
await session.execute(
text("SELECT pg_advisory_unlock(:key)"), {"key": lock_key}
)
await session.commit()
except Exception:
pass
@@ -587,18 +634,23 @@ class SQLStorage(BaseStorage):
async def load_config(self) -> Dict[str, Any]:
await self._ensure_schema()
from sqlalchemy import text
try:
async with self.async_session() as session:
res = await session.execute(text("SELECT section, key_name, value FROM app_config"))
res = await session.execute(
text("SELECT section, key_name, value FROM app_config")
)
rows = res.fetchall()
if not rows: return None
if not rows:
return None
config = {}
for section, key, val_str in rows:
if section not in config: config[section] = {}
if section not in config:
config[section] = {}
try:
val = json_loads(val_str)
except:
except Exception:
val = val_str
config[section][key] = val
return config
@@ -609,21 +661,27 @@ class SQLStorage(BaseStorage):
async def save_config(self, data: Dict[str, Any]):
await self._ensure_schema()
from sqlalchemy import text
try:
async with self.async_session() as session:
for section, items in data.items():
if not isinstance(items, dict): continue
if not isinstance(items, dict):
continue
for key, val in items.items():
val_str = json_dumps(val)
# Upsert 逻辑 (简单实现: Delete + Insert)
await session.execute(
text("DELETE FROM app_config WHERE section=:s AND key_name=:k"),
{"s": section, "k": key}
text(
"DELETE FROM app_config WHERE section=:s AND key_name=:k"
),
{"s": section, "k": key},
)
await session.execute(
text("INSERT INTO app_config (section, key_name, value) VALUES (:s, :k, :v)"),
{"s": section, "k": key, "v": val_str}
text(
"INSERT INTO app_config (section, key_name, value) VALUES (:s, :k, :v)"
),
{"s": section, "k": key, "v": val_str},
)
await session.commit()
except Exception as e:
@@ -633,23 +691,26 @@ class SQLStorage(BaseStorage):
async def load_tokens(self) -> Dict[str, Any]:
await self._ensure_schema()
from sqlalchemy import text
try:
async with self.async_session() as session:
res = await session.execute(text("SELECT pool_name, data FROM tokens"))
rows = res.fetchall()
if not rows: return None
if not rows:
return None
pools = {}
for pool_name, data_json in rows:
if pool_name not in pools: pools[pool_name] = []
if pool_name not in pools:
pools[pool_name] = []
try:
if isinstance(data_json, str):
t_data = json_loads(data_json)
else:
t_data = data_json
pools[pool_name].append(t_data)
except:
except Exception:
pass
return pools
except Exception as e:
@@ -659,25 +720,30 @@ class SQLStorage(BaseStorage):
async def save_tokens(self, data: Dict[str, Any]):
await self._ensure_schema()
from sqlalchemy import text
try:
async with self.async_session() as session:
await session.execute(text("DELETE FROM tokens"))
await session.execute(text("DELETE FROM tokens"))
params = []
for pool_name, tokens in data.items():
for t in tokens:
params.append({
"token": t.get("token"),
"pool_name": pool_name,
"data": json_dumps(t),
"updated_at": 0
})
params.append(
{
"token": t.get("token"),
"pool_name": pool_name,
"data": json_dumps(t),
"updated_at": 0,
}
)
if params:
# 批量插入
await session.execute(
text("INSERT INTO tokens (token, pool_name, data, updated_at) VALUES (:token, :pool_name, :data, :updated_at)"),
params
text(
"INSERT INTO tokens (token, pool_name, data, updated_at) VALUES (:token, :pool_name, :data, :updated_at)"
),
params,
)
await session.commit()
except Exception as e:
@@ -690,6 +756,7 @@ class SQLStorage(BaseStorage):
class StorageFactory:
"""存储后端工厂"""
_instance: Optional[BaseStorage] = None
@staticmethod
@@ -698,42 +765,45 @@ class StorageFactory:
return url
if storage_type == "mysql":
if url.startswith("mysql://"):
return f"mysql+aiomysql://{url[len('mysql://'):]}"
return f"mysql+aiomysql://{url[len('mysql://') :]}"
if url.startswith("mariadb://"):
return f"mariadb+aiomysql://{url[len('mariadb://'):]}"
return f"mariadb+aiomysql://{url[len('mariadb://') :]}"
if storage_type == "pgsql":
if url.startswith("postgres://"):
return f"postgresql+asyncpg://{url[len('postgres://'):]}"
return f"postgresql+asyncpg://{url[len('postgres://') :]}"
if url.startswith("postgresql://"):
return f"postgresql+asyncpg://{url[len('postgresql://'):]}"
return f"postgresql+asyncpg://{url[len('postgresql://') :]}"
if url.startswith("pgsql://"):
return f"postgresql+asyncpg://{url[len('pgsql://'):]}"
return f"postgresql+asyncpg://{url[len('pgsql://') :]}"
return url
@classmethod
def get_storage(cls) -> BaseStorage:
"""获取全局存储实例 (单例)"""
if cls._instance:
return cls._instance
storage_type = os.getenv("SERVER_STORAGE_TYPE", "local").lower()
storage_url = os.getenv("SERVER_STORAGE_URL", "")
logger.info(f"StorageFactory: 初始化存储后端: {storage_type}")
if storage_type == "redis":
if not storage_url: raise ValueError("Redis 存储需要设置 SERVER_STORAGE_URL")
if not storage_url:
raise ValueError("Redis 存储需要设置 SERVER_STORAGE_URL")
cls._instance = RedisStorage(storage_url)
elif storage_type in ("mysql", "pgsql"):
if not storage_url: raise ValueError("SQL 存储需要设置 SERVER_STORAGE_URL")
if not storage_url:
raise ValueError("SQL 存储需要设置 SERVER_STORAGE_URL")
storage_url = cls._normalize_sql_url(storage_type, storage_url)
cls._instance = SQLStorage(storage_url)
else:
cls._instance = LocalStorage()
return cls._instance
def get_storage() -> BaseStorage:
return StorageFactory.get_storage()
+208 -156
View File
@@ -11,6 +11,7 @@ import re
import uuid
from pathlib import Path
from contextlib import asynccontextmanager
try:
import fcntl
except ImportError: # pragma: no cover - non-posix platforms
@@ -23,11 +24,7 @@ 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,
UpstreamException,
ValidationException
)
from app.core.exceptions import AppException, UpstreamException, ValidationException
from app.services.grok.statsig import StatsigService
from app.services.token.service import TokenService
@@ -50,6 +47,7 @@ DEFAULT_DELETE_BATCH_SIZE = 10
_ASSETS_SEMAPHORE = asyncio.Semaphore(DEFAULT_MAX_CONCURRENT)
_ASSETS_SEM_VALUE = DEFAULT_MAX_CONCURRENT
def _get_assets_semaphore() -> asyncio.Semaphore:
global _ASSETS_SEMAPHORE, _ASSETS_SEM_VALUE
value = get_config("performance.assets_max_concurrent", DEFAULT_MAX_CONCURRENT)
@@ -63,14 +61,18 @@ def _get_assets_semaphore() -> asyncio.Semaphore:
_ASSETS_SEMAPHORE = asyncio.Semaphore(value)
return _ASSETS_SEMAPHORE
def _get_delete_batch_size() -> int:
value = get_config("performance.assets_delete_batch_size", DEFAULT_DELETE_BATCH_SIZE)
value = get_config(
"performance.assets_delete_batch_size", DEFAULT_DELETE_BATCH_SIZE
)
try:
value = int(value)
except Exception:
value = DEFAULT_DELETE_BATCH_SIZE
return max(1, value)
@asynccontextmanager
async def _file_lock(name: str, timeout: int = 10):
if fcntl is None:
@@ -105,53 +107,77 @@ async def _file_lock(name: str, timeout: int = 10):
except Exception:
pass
MIME_TYPES = {
# 图片
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
# 文档
'.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.rtf': 'application/rtf',
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".rtf": "application/rtf",
# 表格
'.csv': 'text/csv',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
".csv": "text/csv",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
# 代码
'.py': 'text/x-python-script', '.js': 'application/javascript', '.ts': 'application/typescript',
'.java': 'text/x-java', '.cpp': 'text/x-c++', '.c': 'text/x-c',
'.go': 'text/x-go', '.rs': 'text/x-rust', '.rb': 'text/x-ruby',
'.php': 'text/x-php', '.sh': 'application/x-sh', '.html': 'text/html',
'.css': 'text/css', '.sql': 'application/sql',
".py": "text/x-python-script",
".js": "application/javascript",
".ts": "application/typescript",
".java": "text/x-java",
".cpp": "text/x-c++",
".c": "text/x-c",
".go": "text/x-go",
".rs": "text/x-rust",
".rb": "text/x-ruby",
".php": "text/x-php",
".sh": "application/x-sh",
".html": "text/html",
".css": "text/css",
".sql": "application/sql",
# 数据
'.json': 'application/json', '.xml': 'application/xml', '.yaml': 'application/x-yaml',
'.yml': 'application/x-yaml', '.toml': 'application/toml', '.ini': 'text/plain',
'.log': 'text/plain', '.tmp': 'application/octet-stream',
".json": "application/json",
".xml": "application/xml",
".yaml": "application/x-yaml",
".yml": "application/x-yaml",
".toml": "application/toml",
".ini": "text/plain",
".log": "text/plain",
".tmp": "application/octet-stream",
# 其他
'.graphql': 'application/graphql', '.proto': 'application/x-protobuf',
'.latex': 'application/x-latex', '.wiki': 'text/plain', '.rst': 'text/x-rst',
".graphql": "application/graphql",
".proto": "application/x-protobuf",
".latex": "application/x-latex",
".wiki": "text/plain",
".rst": "text/x-rst",
}
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
VIDEO_EXTS = {'.mp4', '.mov', '.m4v', '.webm', '.avi', '.mkv'}
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
# ==================== 基础服务 ====================
class BaseService:
"""基础服务类"""
def __init__(self, proxy: str = None):
self.proxy = proxy or get_config("grok.asset_proxy_url") or get_config("grok.base_proxy_url", "")
self.proxy = (
proxy
or get_config("grok.asset_proxy_url")
or get_config("grok.base_proxy_url", "")
)
self.timeout = get_config("grok.timeout", TIMEOUT)
self._session: Optional[AsyncSession] = None
def _headers(self, token: str, referer: str = "https://grok.com/") -> dict:
"""构建请求头"""
headers = {
@@ -176,22 +202,22 @@ class BaseService:
"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 _proxies(self) -> Optional[dict]:
"""构建代理配置"""
return {"http": self.proxy, "https": self.proxy} if self.proxy else None
def _dl_headers(self, token: str, file_path: str) -> dict:
"""构建下载请求头"""
headers = {
@@ -203,40 +229,43 @@ class BaseService:
"Upgrade-Insecure-Requests": "1",
"Referer": "https://grok.com/",
}
# 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
async def _get_session(self) -> AsyncSession:
"""获取复用 Session"""
if self._session is None:
self._session = AsyncSession()
return self._session
async def close(self):
"""关闭 Session"""
if self._session:
await self._session.close()
self._session = None
@staticmethod
def is_url(input_str: str) -> bool:
"""检查是否为 URL"""
try:
result = urlparse(input_str)
return all([result.scheme, result.netloc]) and result.scheme in ['http', 'https']
except:
return all([result.scheme, result.netloc]) and result.scheme in [
"http",
"https",
]
except Exception:
return False
@staticmethod
async def fetch(url: str) -> Tuple[str, str, str]:
"""
获取远程资源并转 Base64
Raises:
UpstreamException: 当获取失败时
"""
@@ -246,21 +275,25 @@ class BaseService:
if response.status_code >= 400:
raise UpstreamException(
message=f"Failed to fetch resource: {response.status_code}",
details={"url": url, "status": response.status_code}
details={"url": url, "status": response.status_code},
)
filename = url.split('/')[-1].split('?')[0] or 'download'
content_type = response.headers.get('content-type', DEFAULT_MIME).split(';')[0]
filename = url.split("/")[-1].split("?")[0] or "download"
content_type = response.headers.get("content-type", DEFAULT_MIME).split(
";"
)[0]
b64 = base64.b64encode(response.content).decode()
logger.debug(f"Fetched: {url} -> {filename}")
return filename, b64, content_type
except Exception as e:
logger.error(f"Fetch failed: {url} - {e}")
if isinstance(e, AppException):
raise e
raise UpstreamException(f"Resource fetch failed: {str(e)}", details={"url": url})
raise UpstreamException(
f"Resource fetch failed: {str(e)}", details={"url": url}
)
@staticmethod
def parse_b64(data_uri: str) -> Tuple[str, str, str]:
"""解析 Base64 数据"""
@@ -269,10 +302,10 @@ class BaseService:
if match:
mime = match.group(1)
b64 = match.group(2)
ext = mime.split('/')[-1] if '/' in mime else 'bin'
ext = mime.split("/")[-1] if "/" in mime else "bin"
return f"file.{ext}", b64, mime
return "file.bin", data_uri, DEFAULT_MIME
@staticmethod
def to_b64(file_path: Path, mime_type: str) -> str:
"""将本地文件转为 base64 data URI"""
@@ -281,21 +314,24 @@ class BaseService:
return f"data:{mime_type};base64,{b64_data}"
except Exception as e:
logger.error(f"File to base64 failed: {file_path} - {e}")
raise AppException(f"Failed to read file: {file_path}", code="file_read_error")
raise AppException(
f"Failed to read file: {file_path}", code="file_read_error"
)
# ==================== 上传服务 ====================
class UploadService(BaseService):
"""文件上传服务"""
async def upload(self, file_input: str, token: str) -> Tuple[str, str]:
"""
上传文件到 Grok
Returns:
(file_id, file_uri)
Raises:
ValidationException: 输入无效
UpstreamException: 上传失败
@@ -307,10 +343,10 @@ class UploadService(BaseService):
filename, b64, mime = await self.fetch(file_input)
else:
filename, b64, mime = self.parse_b64(file_input)
if not b64:
raise ValidationException("Invalid file input: empty content")
# 构建请求
headers = self._headers(token)
payload = {
@@ -318,7 +354,7 @@ class UploadService(BaseService):
"fileMimeType": mime,
"content": b64,
}
# 执行上传
session = await self._get_session()
response = await session.post(
@@ -329,25 +365,28 @@ class UploadService(BaseService):
timeout=self.timeout,
proxies=self._proxies(),
)
if response.status_code == 200:
result = response.json()
file_id = result.get("fileMetadataId", "")
file_uri = result.get("fileUri", "")
logger.info(f"Upload success: {filename} -> {file_id}", extra={"file_id": file_id})
logger.info(
f"Upload success: {filename} -> {file_id}",
extra={"file_id": file_id},
)
return file_id, file_uri
# 401/403 表示 token 失效,记录失败并标记冷却
if response.status_code in (401, 403):
logger.warning(
f"Upload token invalid: {response.status_code}, recording failure",
extra={"response": response.text[:200]}
extra={"response": response.text[:200]},
)
try:
await TokenService.record_fail(
token,
status_code=response.status_code,
reason=f"upload_auth_failed"
reason="upload_auth_failed",
)
except Exception as mark_err:
logger.error(f"Failed to record token failure: {mark_err}")
@@ -357,19 +396,22 @@ class UploadService(BaseService):
details={
"status": response.status_code,
"response": response.text[:200],
"token_invalidated": True
}
"token_invalidated": True,
},
)
logger.error(
f"Upload failed: {filename} - {response.status_code}",
extra={"response": response.text[:200]}
extra={"response": response.text[:200]},
)
raise UpstreamException(
message=f"Upload failed with status {response.status_code}",
details={"status": response.status_code, "response": response.text[:200]}
details={
"status": response.status_code,
"response": response.text[:200],
},
)
except Exception as e:
logger.error(f"Upload error: {e}")
if isinstance(e, AppException):
@@ -379,9 +421,10 @@ class UploadService(BaseService):
# ==================== 列表服务 ====================
class ListService(BaseService):
"""文件列表查询服务"""
async def iter_assets(self, token: str):
"""
分页迭代资产列表
@@ -401,7 +444,9 @@ class ListService(BaseService):
params = dict(base_params)
if page_token:
if page_token in seen_tokens:
logger.warning("List pagination stopped due to repeated page token")
logger.warning(
"List pagination stopped due to repeated page token"
)
break
seen_tokens.add(page_token)
params["pageToken"] = page_token
@@ -419,7 +464,7 @@ class ListService(BaseService):
logger.error(f"List failed: {response.status_code}")
raise UpstreamException(
message=f"List assets failed: {response.status_code}",
details={"status": response.status_code}
details={"status": response.status_code},
)
result = response.json()
@@ -433,7 +478,7 @@ class ListService(BaseService):
async def list(self, token: str) -> List[Dict]:
"""
查询文件列表
Raises:
UpstreamException: 查询失败
"""
@@ -444,7 +489,7 @@ class ListService(BaseService):
logger.info(f"List success: {len(assets)} files")
return assets
except Exception as e:
logger.error(f"List error: {e}")
if isinstance(e, AppException):
@@ -469,13 +514,14 @@ class ListService(BaseService):
# ==================== 删除服务 ====================
class DeleteService(BaseService):
"""文件删除服务"""
async def delete(self, token: str, asset_id: str) -> bool:
"""
删除单个文件
Raises:
UpstreamException: 删除失败
"""
@@ -483,7 +529,7 @@ class DeleteService(BaseService):
try:
headers = self._headers(token, referer="https://grok.com/files")
url = f"{DELETE_API}/{asset_id}"
session = await self._get_session()
response = await session.delete(
url,
@@ -492,26 +538,26 @@ class DeleteService(BaseService):
timeout=self.timeout,
proxies=self._proxies(),
)
if response.status_code == 200:
logger.debug(f"Delete success: {asset_id}")
return True
logger.error(f"Delete failed: {asset_id} - {response.status_code}")
#: Note: Returning False or raising Exception?
#: Note: Returning False or raising Exception?
#: Assuming caller handles Exception for stricter control, or False for loose control.
#: Given "optimization" and "standardization", raising exceptions is better for API feedback.
raise UpstreamException(
message=f"Delete failed: {asset_id}",
details={"status": response.status_code}
details={"status": response.status_code},
)
except Exception as e:
logger.error(f"Delete error: {asset_id} - {e}")
if isinstance(e, AppException):
raise e
raise UpstreamException(f"Delete error: {str(e)}")
async def delete_all(self, token: str) -> Dict[str, int]:
"""
删除所有文件
@@ -533,16 +579,16 @@ class DeleteService(BaseService):
if asset_id:
try:
return await self.delete(token, asset_id)
except:
except Exception:
return False
return False
batch_size = _get_delete_batch_size()
for i in range(0, len(assets), batch_size):
batch = assets[i:i + batch_size]
results = await asyncio.gather(*[
_delete_one(asset, idx) for idx, asset in enumerate(batch)
])
batch = assets[i : i + batch_size]
results = await asyncio.gather(
*[_delete_one(asset, idx) for idx, asset in enumerate(batch)]
)
success += sum(results)
failed += len(batch) - sum(results)
@@ -561,9 +607,10 @@ class DeleteService(BaseService):
# ==================== 下载服务 ====================
class DownloadService(BaseService):
"""文件下载服务"""
def __init__(self, proxy: str = None):
super().__init__(proxy)
# 创建缓存目录
@@ -573,24 +620,26 @@ class DownloadService(BaseService):
self.image_dir.mkdir(parents=True, exist_ok=True)
self.video_dir.mkdir(parents=True, exist_ok=True)
self._cleanup_running = False
def _cache_path(self, file_path: str, media_type: str) -> Path:
"""获取缓存路径"""
cache_dir = self.image_dir if media_type == "image" else self.video_dir
filename = file_path.lstrip('/').replace('/', '-')
filename = file_path.lstrip("/").replace("/", "-")
return cache_dir / filename
async def download(self, file_path: str, token: str, media_type: str = "image") -> Tuple[Optional[Path], str]:
async def download(
self, file_path: str, token: str, media_type: str = "image"
) -> Tuple[Optional[Path], str]:
"""
下载文件到本地
Raises:
UpstreamException: 下载失败
"""
async with _get_assets_semaphore():
try:
cache_path = self._cache_path(file_path, media_type)
# 如果已缓存
if cache_path.exists():
logger.debug(f"Cache hit: {cache_path}")
@@ -602,16 +651,18 @@ class DownloadService(BaseService):
# Double-check after lock
if cache_path.exists():
logger.debug(f"Cache hit after lock: {cache_path}")
mime_type = MIME_TYPES.get(cache_path.suffix.lower(), DEFAULT_MIME)
mime_type = MIME_TYPES.get(
cache_path.suffix.lower(), DEFAULT_MIME
)
return cache_path, mime_type
# 下载文件
if not file_path.startswith("/"):
file_path = f"/{file_path}"
url = f"{DOWNLOAD_API}{file_path}"
headers = self._dl_headers(token, file_path)
session = await self._get_session()
response = await session.get(
url,
@@ -622,13 +673,13 @@ class DownloadService(BaseService):
impersonate=BROWSER,
stream=True,
)
if response.status_code != 200:
raise UpstreamException(
message=f"Download failed: {response.status_code}",
details={"path": file_path, "status": response.status_code}
details={"path": file_path, "status": response.status_code},
)
# 保存文件(分块写入,避免大文件占用内存)
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
try:
@@ -654,48 +705,47 @@ class DownloadService(BaseService):
tmp_path.unlink()
except Exception:
pass
mime_type = response.headers.get('content-type', DEFAULT_MIME).split(';')[0]
mime_type = response.headers.get(
"content-type", DEFAULT_MIME
).split(";")[0]
logger.info(f"Download success: {file_path}")
# 检查缓存限制
asyncio.create_task(self.check_limit())
return cache_path, mime_type
except Exception as e:
logger.error(f"Download failed: {file_path} - {e}")
if isinstance(e, AppException):
raise e
raise UpstreamException(f"Download error: {str(e)}")
async def to_base64(
self,
file_path: str,
token: str,
media_type: str = "image"
self, file_path: str, token: str, media_type: str = "image"
) -> str:
"""
下载文件并转为 base64
"""
try:
cache_path, mime_type = await self.download(file_path, token, media_type)
if not cache_path or not cache_path.exists():
raise AppException("File download returned invalid path")
# 使用基础服务的工具方法转换
data_uri = self.to_b64(cache_path, mime_type)
# 删除临时文件
if data_uri:
try:
cache_path.unlink()
except Exception as e:
logger.warning(f"Delete temp file failed: {e}")
return data_uri
except Exception as e:
logger.error(f"To base64 failed: {file_path} - {e}")
if isinstance(e, AppException):
@@ -707,18 +757,17 @@ class DownloadService(BaseService):
cache_dir = self.image_dir if media_type == "image" else self.video_dir
if not cache_dir.exists():
return {"count": 0, "size_mb": 0.0}
files = [f for f in cache_dir.glob("*") if f.is_file()]
allowed_exts = IMAGE_EXTS if media_type == "image" else VIDEO_EXTS
files = [f for f in files if f.suffix.lower() in allowed_exts]
total_size = sum(f.stat().st_size for f in files)
return {
"count": len(files),
"size_mb": round(total_size / 1024 / 1024, 2)
}
def list_files(self, media_type: str = "image", page: int = 1, page_size: int = 1000) -> Dict[str, Any]:
return {"count": len(files), "size_mb": round(total_size / 1024 / 1024, 2)}
def list_files(
self, media_type: str = "image", page: int = 1, page_size: int = 1000
) -> Dict[str, Any]:
"""列出本地缓存文件"""
cache_dir = self.image_dir if media_type == "image" else self.video_dir
if not cache_dir.exists():
@@ -731,11 +780,13 @@ class DownloadService(BaseService):
for f in files:
try:
stat = f.stat()
items.append({
"name": f.name,
"size_bytes": stat.st_size,
"mtime_ms": int(stat.st_mtime * 1000),
})
items.append(
{
"name": f.name,
"size_bytes": stat.st_size,
"mtime_ms": int(stat.st_mtime * 1000),
}
)
except Exception:
continue
@@ -774,29 +825,26 @@ class DownloadService(BaseService):
return {"deleted": True}
except Exception:
return {"deleted": False}
def clear(self, media_type: str = "image") -> Dict[str, Any]:
"""清空缓存"""
cache_dir = self.image_dir if media_type == "image" else self.video_dir
if not cache_dir.exists():
return {"count": 0, "size_mb": 0.0}
files = list(cache_dir.glob("*"))
total_size = sum(f.stat().st_size for f in files)
count = 0
for f in files:
try:
f.unlink()
count += 1
except Exception as e:
logger.error(f"Failed to delete {f}: {e}")
return {
"count": count,
"size_mb": round(total_size / 1024 / 1024, 2)
}
return {"count": count, "size_mb": round(total_size / 1024 / 1024, 2)}
async def check_limit(self):
"""检查并清理缓存限制"""
if self._cleanup_running:
@@ -812,7 +860,7 @@ class DownloadService(BaseService):
# 统计总大小
total_size = 0
all_files = []
for d in [self.image_dir, self.video_dir]:
if d.exists():
for f in d.glob("*"):
@@ -820,52 +868,56 @@ class DownloadService(BaseService):
stat = f.stat()
total_size += stat.st_size
all_files.append((f, stat.st_mtime, stat.st_size))
except:
except Exception:
pass
current_mb = total_size / 1024 / 1024
if current_mb <= limit_mb:
return
# 需要清理
logger.info(f"Cache limit exceeded ({current_mb:.2f}MB > {limit_mb}MB), cleaning up...")
logger.info(
f"Cache limit exceeded ({current_mb:.2f}MB > {limit_mb}MB), cleaning up..."
)
# 按时间排序
all_files.sort(key=lambda x: x[1])
deleted_count = 0
deleted_size = 0
target_mb = limit_mb * 0.8 # 清理到 80%
for f, _, size in all_files:
try:
f.unlink()
deleted_count += 1
deleted_size += size
total_size -= size
if (total_size / 1024 / 1024) <= target_mb:
break
except Exception as e:
logger.error(f"Cleanup failed for {f}: {e}")
logger.info(f"Cache cleanup: deleted {deleted_count} files ({deleted_size/1024/1024:.2f}MB)")
logger.info(
f"Cache cleanup: deleted {deleted_count} files ({deleted_size / 1024 / 1024:.2f}MB)"
)
finally:
self._cleanup_running = False
def get_public_url(self, file_path: str) -> str:
"""
获取文件的公共访问 URL
如果配置了 app_url,则返回自托管 URL,否则返回 Grok 原始 URL
"""
app_url = get_config("app.app_url", "")
if not app_url:
return f"{DOWNLOAD_API}{file_path if file_path.startswith('/') else '/' + file_path}"
if not file_path.startswith("/"):
file_path = f"/{file_path}"
# 自动添加 /v1/files 前缀
return f"{app_url.rstrip('/')}/v1/files{file_path}"
+3 -3
View File
@@ -329,7 +329,7 @@ class GrokChatService:
try:
content = await response.text()
content = content[:1000] # 限制长度避免日志过大
except:
except Exception:
content = "Unable to read response content"
logger.error(
@@ -342,7 +342,7 @@ class GrokChatService:
# 关闭 session 并抛出异常
try:
await session.close()
except:
except Exception:
pass
raise UpstreamException(
message=f"Grok API request failed: {response.status_code}",
@@ -360,7 +360,7 @@ class GrokChatService:
logger.error(f"Chat request error: {e}")
try:
await session.close()
except:
except Exception:
pass
raise UpstreamException(
message=f"Chat connection failed: {str(e)}",
+4 -4
View File
@@ -266,7 +266,7 @@ class VideoService:
logger.error(f"Video generation failed: {response.status_code}")
try:
await session.close()
except:
except Exception:
pass
raise UpstreamException(
message=f"Video generation failed: {response.status_code}",
@@ -288,7 +288,7 @@ class VideoService:
if session:
try:
await session.close()
except:
except Exception:
pass
logger.error(f"Video generation error: {e}")
if isinstance(e, AppException):
@@ -348,7 +348,7 @@ class VideoService:
logger.error(f"Video from image failed: {response.status_code}")
try:
await session.close()
except:
except Exception:
pass
raise UpstreamException(
message=f"Video from image failed: {response.status_code}",
@@ -370,7 +370,7 @@ class VideoService:
if session:
try:
await session.close()
except:
except Exception:
pass
logger.error(f"Video from image error: {e}")
if isinstance(e, AppException):
+19 -16
View File
@@ -11,18 +11,21 @@ from app.core.exceptions import ValidationException
class Tier(str, Enum):
"""模型档位"""
BASIC = "basic"
SUPER = "super"
class Cost(str, Enum):
"""计费类型"""
LOW = "low"
HIGH = "high"
class ModelInfo(BaseModel):
"""模型信息"""
model_id: str
grok_model: str
model_mode: str
@@ -36,42 +39,42 @@ class ModelInfo(BaseModel):
class ModelService:
"""模型管理服务"""
MODELS = [
ModelInfo(
model_id="grok-3",
grok_model="grok-3",
model_mode="MODEL_MODE_AUTO",
cost=Cost.LOW,
display_name="Grok 3"
display_name="Grok 3",
),
ModelInfo(
model_id="grok-3-fast",
grok_model="grok-3",
cost=Cost.LOW,
model_mode="MODEL_MODE_FAST",
display_name="Grok 3 Fast"
display_name="Grok 3 Fast",
),
ModelInfo(
model_id="grok-4",
grok_model="grok-4",
model_mode="MODEL_MODE_AUTO",
cost=Cost.LOW,
display_name="Grok 4"
display_name="Grok 4",
),
ModelInfo(
model_id="grok-4-mini",
grok_model="grok-4-mini-thinking-tahoe",
model_mode="MODEL_MODE_GROK_4_MINI_THINKING",
cost=Cost.LOW,
display_name="Grok 4 Mini"
display_name="Grok 4 Mini",
),
ModelInfo(
model_id="grok-4-fast",
grok_model="grok-4",
model_mode="MODEL_MODE_FAST",
cost=Cost.LOW,
display_name="Grok 4 Fast"
display_name="Grok 4 Fast",
),
ModelInfo(
model_id="grok-4-heavy",
@@ -79,21 +82,21 @@ class ModelService:
model_mode="MODEL_MODE_HEAVY",
cost=Cost.HIGH,
tier=Tier.SUPER,
display_name="Grok 4 Heavy"
display_name="Grok 4 Heavy",
),
ModelInfo(
model_id="grok-4.1",
grok_model="grok-4-1-thinking-1129",
model_mode="MODEL_MODE_AUTO",
cost=Cost.LOW,
display_name="Grok 4.1"
display_name="Grok 4.1",
),
ModelInfo(
model_id="grok-4.1-thinking",
grok_model="grok-4-1-thinking-1129",
model_mode="MODEL_MODE_GROK_4_1_THINKING",
cost=Cost.HIGH,
display_name="Grok 4.1 Thinking"
cost=Cost.HIGH,
display_name="Grok 4.1 Thinking",
),
ModelInfo(
model_id="grok-imagine-1.0",
@@ -102,7 +105,7 @@ class ModelService:
cost=Cost.HIGH,
display_name="Grok Image",
description="Image generation model",
is_image=True
is_image=True,
),
ModelInfo(
model_id="grok-imagine-1.0-video",
@@ -111,22 +114,22 @@ class ModelService:
cost=Cost.HIGH,
display_name="Grok Video",
description="Video generation model",
is_video=True
is_video=True,
),
]
_map = {m.model_id: m for m in MODELS}
@classmethod
def get(cls, model_id: str) -> Optional[ModelInfo]:
"""获取模型信息"""
return cls._map.get(model_id)
@classmethod
def list(cls) -> list[ModelInfo]:
"""获取所有模型"""
return list(cls._map.values())
@classmethod
def valid(cls, model_id: str) -> bool:
"""模型是否有效"""
+274 -159
View File
@@ -1,6 +1,7 @@
"""
OpenAI 响应格式处理器
"""
import asyncio
import time
import uuid
@@ -27,15 +28,14 @@ T = TypeVar("T")
class StreamIdleTimeoutError(Exception):
"""流空闲超时错误"""
def __init__(self, idle_seconds: float):
self.idle_seconds = idle_seconds
super().__init__(f"Stream idle timeout after {idle_seconds}s")
async def _with_idle_timeout(
iterable: AsyncIterable[T],
idle_timeout: float,
model: str = ""
iterable: AsyncIterable[T], idle_timeout: float, model: str = ""
) -> AsyncGenerator[T, None]:
"""
包装异步迭代器,添加空闲超时检测
@@ -59,15 +59,12 @@ async def _with_idle_timeout(
iterator = iterable.__aiter__()
while True:
try:
item = await asyncio.wait_for(
iterator.__anext__(),
timeout=idle_timeout
)
item = await asyncio.wait_for(iterator.__anext__(), timeout=idle_timeout)
yield item
except asyncio.TimeoutError:
logger.warning(
f"Stream idle timeout after {idle_timeout}s",
extra={"model": model, "idle_timeout": idle_timeout}
extra={"model": model, "idle_timeout": idle_timeout},
)
raise StreamIdleTimeoutError(idle_timeout)
except StopAsyncIteration:
@@ -79,7 +76,7 @@ ASSET_URL = "https://assets.grok.com/"
class BaseProcessor:
"""基础处理器"""
def __init__(self, model: str, token: str = ""):
self.model = model
self.token = token
@@ -104,39 +101,44 @@ class BaseProcessor:
# 处理可能的绝对路径
if path.startswith("http"):
from urllib.parse import urlparse
path = urlparse(path).path
if not path.startswith("/"):
path = f"/{path}"
if self.app_url:
dl_service = self._get_dl()
await dl_service.download(path, self.token, media_type)
return f"{self.app_url.rstrip('/')}/v1/files/{media_type}{path}"
else:
return f"{ASSET_URL.rstrip('/')}{path}"
def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
"""构建 SSE 响应 (StreamProcessor 通用)"""
if not hasattr(self, 'response_id'):
if not hasattr(self, "response_id"):
self.response_id = None
if not hasattr(self, 'fingerprint'):
if not hasattr(self, "fingerprint"):
self.fingerprint = ""
delta = {}
if role:
delta["role"] = role
delta["content"] = ""
elif content:
delta["content"] = content
chunk = {
"id": self.response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}",
"object": "chat.completion.chunk",
"created": self.created,
"model": self.model,
"system_fingerprint": self.fingerprint if hasattr(self, 'fingerprint') else "",
"choices": [{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}]
"system_fingerprint": self.fingerprint
if hasattr(self, "fingerprint")
else "",
"choices": [
{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}
],
}
return f"data: {orjson.dumps(chunk).decode()}\n\n"
@@ -178,15 +180,15 @@ class StreamProcessor(BaseProcessor):
if self._in_filter_tag:
self._tag_buffer += char
# 检查是否到达结束标签
if char == '>':
if char == ">":
# 检查是否是自闭合标签
if '/>' in self._tag_buffer:
if "/>" in self._tag_buffer:
self._in_filter_tag = False
self._tag_buffer = ""
else:
# 检查是否是结束标签 </{tag}>
for tag in self.filter_tags:
if f'</{tag}>' in self._tag_buffer:
if f"</{tag}>" in self._tag_buffer:
self._in_filter_tag = False
self._tag_buffer = ""
break
@@ -196,18 +198,18 @@ class StreamProcessor(BaseProcessor):
continue
# 检查是否开始一个过滤标签
if char == '<':
if char == "<":
# 查看后续字符
remaining = token[i:]
tag_started = False
for tag in self.filter_tags:
if remaining.startswith(f'<{tag}'):
if remaining.startswith(f"<{tag}"):
tag_started = True
break
# 部分匹配(可能跨 token)
if len(remaining) < len(tag) + 1:
for j in range(1, len(remaining) + 1):
if f'<{tag}'.startswith(remaining[:j]):
if f"<{tag}".startswith(remaining[:j]):
tag_started = True
break
@@ -220,9 +222,11 @@ class StreamProcessor(BaseProcessor):
result.append(char)
i += 1
return ''.join(result)
async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, None]:
return "".join(result)
async def process(
self, response: AsyncIterable[bytes]
) -> AsyncGenerator[str, None]:
"""处理流式响应"""
# 获取空闲超时配置
idle_timeout = get_config("grok.stream_idle_timeout", 45.0)
@@ -235,31 +239,33 @@ class StreamProcessor(BaseProcessor):
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
# 元数据
if (llm := resp.get("llmInfo")) and not self.fingerprint:
self.fingerprint = llm.get("modelHash", "")
if rid := resp.get("responseId"):
self.response_id = rid
# 首次发送 role
if not self.role_sent:
yield self._sse(role="assistant")
self.role_sent = True
# 图像生成进度
if img := resp.get("streamingImageGenerationResponse"):
if self.show_think:
if not self.think_opened:
yield self._sse("<think>\n")
self.think_opened = True
idx = img.get('imageIndex', 0) + 1
progress = img.get('progress', 0)
yield self._sse(f"正在生成第{idx}张图片中,当前进度{progress}%\n")
idx = img.get("imageIndex", 0) + 1
progress = img.get("progress", 0)
yield self._sse(
f"正在生成第{idx}张图片中,当前进度{progress}%\n"
)
continue
# modelResponse
if mr := resp.get("modelResponse"):
if self.think_opened and self.show_think:
@@ -267,15 +273,17 @@ class StreamProcessor(BaseProcessor):
yield self._sse(msg + "\n")
yield self._sse("</think>\n")
self.think_opened = False
# 处理生成的图片
for url in mr.get("generatedImageUrls", []):
parts = url.split("/")
img_id = parts[-2] if len(parts) >= 2 else "image"
if self.image_format == "base64":
dl_service = self._get_dl()
base64_data = await dl_service.to_base64(url, self.token, "image")
base64_data = await dl_service.to_base64(
url, self.token, "image"
)
if base64_data:
yield self._sse(f"![{img_id}]({base64_data})\n")
else:
@@ -284,18 +292,22 @@ class StreamProcessor(BaseProcessor):
else:
final_url = await self.process_url(url, "image")
yield self._sse(f"![{img_id}]({final_url})\n")
if (meta := mr.get("metadata", {})).get("llm_info", {}).get("modelHash"):
if (
(meta := mr.get("metadata", {}))
.get("llm_info", {})
.get("modelHash")
):
self.fingerprint = meta["llm_info"]["modelHash"]
continue
# 普通 token
if (token := resp.get("token")) is not None:
if token:
filtered = self._filter_token(token)
if filtered:
yield self._sse(filtered)
if self.think_opened:
yield self._sse("</think>\n")
yield self._sse(finish="stop")
@@ -308,7 +320,11 @@ class StreamProcessor(BaseProcessor):
raise UpstreamException(
message=f"Stream idle timeout after {e.idle_seconds}s",
status_code=504,
details={"error": str(e), "type": "stream_idle_timeout", "idle_seconds": e.idle_seconds}
details={
"error": str(e),
"type": "stream_idle_timeout",
"idle_seconds": e.idle_seconds,
},
)
except RequestsError as e:
# HTTP/2 流错误转换为 UpstreamException
@@ -317,16 +333,19 @@ class StreamProcessor(BaseProcessor):
raise UpstreamException(
message="Upstream connection closed unexpectedly",
status_code=502,
details={"error": str(e), "type": "http2_stream_error"}
details={"error": str(e), "type": "http2_stream_error"},
)
logger.error(f"Stream request error: {e}", extra={"model": self.model})
raise UpstreamException(
message=f"Upstream request failed: {e}",
status_code=502,
details={"error": str(e)}
details={"error": str(e)},
)
except Exception as e:
logger.error(f"Stream processing error: {e}", extra={"model": self.model, "error_type": type(e).__name__})
logger.error(
f"Stream processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
raise
finally:
await self.close()
@@ -346,20 +365,21 @@ class CollectProcessor(BaseProcessor):
def _filter_content(self, content: str) -> str:
"""过滤内容中的特殊标签"""
import re
if not content or not self.filter_tags:
return content
result = content
for tag in self.filter_tags:
# 匹配 <tag ...>...</tag> 或 <tag ... />
pattern = rf'<{re.escape(tag)}[^>]*>.*?</{re.escape(tag)}>|<{re.escape(tag)}[^>]*/>'
result = re.sub(pattern, '', result, flags=re.DOTALL)
pattern = rf"<{re.escape(tag)}[^>]*>.*?</{re.escape(tag)}>|<{re.escape(tag)}[^>]*/>"
result = re.sub(pattern, "", result, flags=re.DOTALL)
# 匹配 <tag ...>...</tag> 跨行
pattern2 = rf'<{re.escape(tag)}[^>]*>[\s\S]*?</{re.escape(tag)}>'
result = re.sub(pattern2, '', result, flags=re.DOTALL)
pattern2 = rf"<{re.escape(tag)}[^>]*>[\s\S]*?</{re.escape(tag)}>"
result = re.sub(pattern2, "", result, flags=re.DOTALL)
return result
async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]:
"""处理并收集完整响应"""
response_id = ""
@@ -376,25 +396,27 @@ class CollectProcessor(BaseProcessor):
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
if (llm := resp.get("llmInfo")) and not fingerprint:
fingerprint = llm.get("modelHash", "")
if mr := resp.get("modelResponse"):
response_id = mr.get("responseId", "")
content = mr.get("message", "")
if urls := mr.get("generatedImageUrls"):
content += "\n"
for url in urls:
parts = url.split("/")
img_id = parts[-2] if len(parts) >= 2 else "image"
if self.image_format == "base64":
dl_service = self._get_dl()
base64_data = await dl_service.to_base64(url, self.token, "image")
base64_data = await dl_service.to_base64(
url, self.token, "image"
)
if base64_data:
content += f"![{img_id}]({base64_data})\n"
else:
@@ -403,8 +425,12 @@ class CollectProcessor(BaseProcessor):
else:
final_url = await self.process_url(url, "image")
content += f"![{img_id}]({final_url})\n"
if (meta := mr.get("metadata", {})).get("llm_info", {}).get("modelHash"):
if (
(meta := mr.get("metadata", {}))
.get("llm_info", {})
.get("modelHash")
):
fingerprint = meta["llm_info"]["modelHash"]
except asyncio.CancelledError:
@@ -414,11 +440,16 @@ class CollectProcessor(BaseProcessor):
# 非流式模式下,超时后返回已收集的内容
except RequestsError as e:
if _is_http2_stream_error(e):
logger.warning(f"HTTP/2 stream error in collect: {e}", extra={"model": self.model})
logger.warning(
f"HTTP/2 stream error in collect: {e}", extra={"model": self.model}
)
else:
logger.error(f"Collect request error: {e}", extra={"model": self.model})
except Exception as e:
logger.error(f"Collect processing error: {e}", extra={"model": self.model, "error_type": type(e).__name__})
logger.error(
f"Collect processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
finally:
await self.close()
@@ -431,42 +462,62 @@ class CollectProcessor(BaseProcessor):
"created": self.created,
"model": self.model,
"system_fingerprint": fingerprint,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": content, "refusal": None, "annotations": []},
"finish_reason": "stop"
}],
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
"annotations": [],
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
"prompt_tokens_details": {"cached_tokens": 0, "text_tokens": 0, "audio_tokens": 0, "image_tokens": 0},
"completion_tokens_details": {"text_tokens": 0, "audio_tokens": 0, "reasoning_tokens": 0}
}
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
},
},
}
class VideoStreamProcessor(BaseProcessor):
"""视频流式响应处理器"""
def __init__(self, model: str, token: str = "", think: bool = None):
super().__init__(model, token)
self.response_id: Optional[str] = None
self.think_opened: bool = False
self.role_sent: bool = False
self.video_format = get_config("app.video_format", "url")
if think is None:
self.show_think = get_config("grok.thinking", False)
else:
self.show_think = think
def _build_video_html(self, video_url: str, thumbnail_url: str = "") -> str:
"""构建视频 HTML 标签"""
poster_attr = f' poster="{thumbnail_url}"' if thumbnail_url else ""
return f'''<video id="video" controls="" preload="none"{poster_attr}>
<source id="mp4" src="{video_url}" type="video/mp4">
</video>'''
async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, None]:
async def process(
self, response: AsyncIterable[bytes]
) -> AsyncGenerator[str, None]:
"""处理视频流式响应"""
# 视频生成使用更长的空闲超时
idle_timeout = get_config("grok.video_idle_timeout", 90.0)
@@ -479,92 +530,109 @@ class VideoStreamProcessor(BaseProcessor):
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
if rid := resp.get("responseId"):
self.response_id = rid
# 首次发送 role
if not self.role_sent:
yield self._sse(role="assistant")
self.role_sent = True
# 视频生成进度
if video_resp := resp.get("streamingVideoGenerationResponse"):
progress = video_resp.get("progress", 0)
if self.show_think:
if not self.think_opened:
yield self._sse("<think>\n")
self.think_opened = True
yield self._sse(f"正在生成视频中,当前进度{progress}%\n")
if progress == 100:
video_url = video_resp.get("videoUrl", "")
thumbnail_url = video_resp.get("thumbnailImageUrl", "")
if self.think_opened and self.show_think:
yield self._sse("</think>\n")
self.think_opened = False
if video_url:
final_video_url = await self.process_url(video_url, "video")
final_thumbnail_url = ""
if thumbnail_url:
final_thumbnail_url = await self.process_url(thumbnail_url, "image")
video_html = self._build_video_html(final_video_url, final_thumbnail_url)
final_thumbnail_url = await self.process_url(
thumbnail_url, "image"
)
video_html = self._build_video_html(
final_video_url, final_thumbnail_url
)
yield self._sse(video_html)
logger.info(f"Video generated: {video_url}")
continue
if self.think_opened:
yield self._sse("</think>\n")
yield self._sse(finish="stop")
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
logger.debug("Video stream cancelled by client", extra={"model": self.model})
logger.debug(
"Video stream cancelled by client", extra={"model": self.model}
)
except StreamIdleTimeoutError as e:
raise UpstreamException(
message=f"Video stream idle timeout after {e.idle_seconds}s",
status_code=504,
details={"error": str(e), "type": "stream_idle_timeout", "idle_seconds": e.idle_seconds}
details={
"error": str(e),
"type": "stream_idle_timeout",
"idle_seconds": e.idle_seconds,
},
)
except RequestsError as e:
if _is_http2_stream_error(e):
logger.warning(f"HTTP/2 stream error in video: {e}", extra={"model": self.model})
logger.warning(
f"HTTP/2 stream error in video: {e}", extra={"model": self.model}
)
raise UpstreamException(
message="Upstream connection closed unexpectedly",
status_code=502,
details={"error": str(e), "type": "http2_stream_error"}
details={"error": str(e), "type": "http2_stream_error"},
)
logger.error(f"Video stream request error: {e}", extra={"model": self.model})
logger.error(
f"Video stream request error: {e}", extra={"model": self.model}
)
raise UpstreamException(
message=f"Upstream request failed: {e}",
status_code=502,
details={"error": str(e)}
details={"error": str(e)},
)
except Exception as e:
logger.error(f"Video stream processing error: {e}", extra={"model": self.model, "error_type": type(e).__name__})
logger.error(
f"Video stream processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
finally:
await self.close()
class VideoCollectProcessor(BaseProcessor):
"""视频非流式响应处理器"""
def __init__(self, model: str, token: str = ""):
super().__init__(model, token)
self.video_format = get_config("app.video_format", "url")
def _build_video_html(self, video_url: str, thumbnail_url: str = "") -> str:
poster_attr = f' poster="{thumbnail_url}"' if thumbnail_url else ""
return f'''<video id="video" controls="" preload="none"{poster_attr}>
<source id="mp4" src="{video_url}" type="video/mp4">
</video>'''
async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]:
"""处理并收集视频响应"""
response_id = ""
@@ -580,66 +648,90 @@ class VideoCollectProcessor(BaseProcessor):
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
if video_resp := resp.get("streamingVideoGenerationResponse"):
if video_resp.get("progress") == 100:
response_id = resp.get("responseId", "")
video_url = video_resp.get("videoUrl", "")
thumbnail_url = video_resp.get("thumbnailImageUrl", "")
if video_url:
final_video_url = await self.process_url(video_url, "video")
final_thumbnail_url = ""
if thumbnail_url:
final_thumbnail_url = await self.process_url(thumbnail_url, "image")
content = self._build_video_html(final_video_url, final_thumbnail_url)
final_thumbnail_url = await self.process_url(
thumbnail_url, "image"
)
content = self._build_video_html(
final_video_url, final_thumbnail_url
)
logger.info(f"Video generated: {video_url}")
except asyncio.CancelledError:
logger.debug("Video collect cancelled by client", extra={"model": self.model})
logger.debug(
"Video collect cancelled by client", extra={"model": self.model}
)
except StreamIdleTimeoutError as e:
logger.warning(f"Video collect idle timeout: {e}", extra={"model": self.model})
logger.warning(
f"Video collect idle timeout: {e}", extra={"model": self.model}
)
except RequestsError as e:
if _is_http2_stream_error(e):
logger.warning(f"HTTP/2 stream error in video collect: {e}", extra={"model": self.model})
logger.warning(
f"HTTP/2 stream error in video collect: {e}",
extra={"model": self.model},
)
else:
logger.error(f"Video collect request error: {e}", extra={"model": self.model})
logger.error(
f"Video collect request error: {e}", extra={"model": self.model}
)
except Exception as e:
logger.error(f"Video collect processing error: {e}", extra={"model": self.model, "error_type": type(e).__name__})
logger.error(
f"Video collect processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
finally:
await self.close()
return {
"id": response_id,
"object": "chat.completion",
"created": self.created,
"model": self.model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": content, "refusal": None},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
class ImageStreamProcessor(BaseProcessor):
"""图片生成流式响应处理器"""
def __init__(self, model: str, token: str = "", n: int = 1):
super().__init__(model, token)
self.partial_index = 0
self.n = n
self.target_index = random.randint(0, 1) if n == 1 else None
def _sse(self, event: str, data: dict) -> str:
"""构建 SSE 响应 (覆盖基类)"""
return f"event: {event}\ndata: {orjson.dumps(data).decode()}\n\n"
async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, None]:
async def process(
self, response: AsyncIterable[bytes]
) -> AsyncGenerator[str, None]:
"""处理流式响应"""
final_images = []
# 图片生成使用标准空闲超时
@@ -653,33 +745,38 @@ class ImageStreamProcessor(BaseProcessor):
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
# 图片生成进度
if img := resp.get("streamingImageGenerationResponse"):
image_index = img.get("imageIndex", 0)
progress = img.get("progress", 0)
if self.n == 1 and image_index != self.target_index:
continue
out_index = 0 if self.n == 1 else image_index
yield self._sse("image_generation.partial_image", {
"type": "image_generation.partial_image",
"b64_json": "",
"index": out_index,
"progress": progress
})
yield self._sse(
"image_generation.partial_image",
{
"type": "image_generation.partial_image",
"b64_json": "",
"index": out_index,
"progress": progress,
},
)
continue
# modelResponse
if mr := resp.get("modelResponse"):
if urls := mr.get("generatedImageUrls"):
for url in urls:
dl_service = self._get_dl()
base64_data = await dl_service.to_base64(url, self.token, "image")
base64_data = await dl_service.to_base64(
url, self.token, "image"
)
if base64_data:
if "," in base64_data:
b64 = base64_data.split(",", 1)[1]
@@ -687,7 +784,7 @@ class ImageStreamProcessor(BaseProcessor):
b64 = base64_data
final_images.append(b64)
continue
for index, b64 in enumerate(final_images):
if self.n == 1:
if index != self.target_index:
@@ -695,25 +792,35 @@ class ImageStreamProcessor(BaseProcessor):
out_index = 0
else:
out_index = index
yield self._sse("image_generation.completed", {
"type": "image_generation.completed",
"b64_json": b64,
"index": out_index,
"usage": {
"total_tokens": 50,
"input_tokens": 25,
"output_tokens": 25,
"input_tokens_details": {"text_tokens": 5, "image_tokens": 20}
}
})
yield self._sse(
"image_generation.completed",
{
"type": "image_generation.completed",
"b64_json": b64,
"index": out_index,
"usage": {
"total_tokens": 50,
"input_tokens": 25,
"output_tokens": 25,
"input_tokens_details": {
"text_tokens": 5,
"image_tokens": 20,
},
},
},
)
except asyncio.CancelledError:
logger.debug("Image stream cancelled by client")
except StreamIdleTimeoutError as e:
raise UpstreamException(
message=f"Image stream idle timeout after {e.idle_seconds}s",
status_code=504,
details={"error": str(e), "type": "stream_idle_timeout", "idle_seconds": e.idle_seconds}
details={
"error": str(e),
"type": "stream_idle_timeout",
"idle_seconds": e.idle_seconds,
},
)
except RequestsError as e:
if _is_http2_stream_error(e):
@@ -721,16 +828,19 @@ class ImageStreamProcessor(BaseProcessor):
raise UpstreamException(
message="Upstream connection closed unexpectedly",
status_code=502,
details={"error": str(e), "type": "http2_stream_error"}
details={"error": str(e), "type": "http2_stream_error"},
)
logger.error(f"Image stream request error: {e}")
raise UpstreamException(
message=f"Upstream request failed: {e}",
status_code=502,
details={"error": str(e)}
details={"error": str(e)},
)
except Exception as e:
logger.error(f"Image stream processing error: {e}", extra={"error_type": type(e).__name__})
logger.error(
f"Image stream processing error: {e}",
extra={"error_type": type(e).__name__},
)
raise
finally:
await self.close()
@@ -738,10 +848,10 @@ class ImageStreamProcessor(BaseProcessor):
class ImageCollectProcessor(BaseProcessor):
"""图片生成非流式响应处理器"""
def __init__(self, model: str, token: str = ""):
super().__init__(model, token)
async def process(self, response: AsyncIterable[bytes]) -> List[str]:
"""处理并收集图片"""
images = []
@@ -756,21 +866,23 @@ class ImageCollectProcessor(BaseProcessor):
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
if mr := resp.get("modelResponse"):
if urls := mr.get("generatedImageUrls"):
for url in urls:
dl_service = self._get_dl()
base64_data = await dl_service.to_base64(url, self.token, "image")
base64_data = await dl_service.to_base64(
url, self.token, "image"
)
if base64_data:
if "," in base64_data:
b64 = base64_data.split(",", 1)[1]
else:
b64 = base64_data
images.append(b64)
except asyncio.CancelledError:
logger.debug("Image collect cancelled by client")
except StreamIdleTimeoutError as e:
@@ -781,10 +893,13 @@ class ImageCollectProcessor(BaseProcessor):
else:
logger.error(f"Image collect request error: {e}")
except Exception as e:
logger.error(f"Image collect processing error: {e}", extra={"error_type": type(e).__name__})
logger.error(
f"Image collect processing error: {e}",
extra={"error_type": type(e).__name__},
)
finally:
await self.close()
return images
+12 -12
View File
@@ -88,7 +88,9 @@ class RetryContext:
self.last_error = error
self.attempt += 1
def calculate_delay(self, status_code: int, retry_after: Optional[float] = None) -> float:
def calculate_delay(
self, status_code: int, retry_after: Optional[float] = None
) -> float:
"""
计算退避延迟时间
@@ -114,7 +116,7 @@ class RetryContext:
return delay
# 其他状态码使用指数退避 + full jitter
exp_delay = self.backoff_base * (self.backoff_factor ** self.attempt)
exp_delay = self.backoff_base * (self.backoff_factor**self.attempt)
delay = random.uniform(0, min(exp_delay, self.backoff_max))
return delay
@@ -164,7 +166,7 @@ async def retry_on_status(
*args,
extract_status: Callable[[Exception], Optional[int]] = None,
on_retry: Callable[[int, int, Exception, float], None] = None,
**kwargs
**kwargs,
) -> Any:
"""
通用重试函数
@@ -186,6 +188,7 @@ async def retry_on_status(
# 状态码提取器
if extract_status is None:
def extract_status(e: Exception) -> Optional[int]:
if isinstance(e, UpstreamException):
# 优先从 details 获取,回退到 status_code 属性
@@ -256,9 +259,7 @@ async def retry_on_status(
f"last status: {status_code}, total delay: {ctx.total_delay:.2f}s"
)
else:
logger.error(
f"Non-retryable status code: {status_code}"
)
logger.error(f"Non-retryable status code: {status_code}")
# 抛出最后一次的错误
raise
@@ -266,7 +267,7 @@ async def retry_on_status(
def with_retry(
extract_status: Callable[[Exception], Optional[int]] = None,
on_retry: Callable[[int, int, Exception, float], None] = None
on_retry: Callable[[int, int, Exception, float], None] = None,
):
"""
重试装饰器
@@ -276,17 +277,16 @@ def with_retry(
async def my_api_call():
...
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
return await retry_on_status(
func,
*args,
extract_status=extract_status,
on_retry=on_retry,
**kwargs
func, *args, extract_status=extract_status, on_retry=on_retry, **kwargs
)
return wrapper
return decorator
+14 -8
View File
@@ -11,35 +11,41 @@ from app.core.config import get_config
class StatsigService:
"""Statsig ID 生成服务"""
@staticmethod
def _rand(length: int, alphanumeric: bool = False) -> str:
"""生成随机字符串"""
chars = string.ascii_lowercase + string.digits if alphanumeric else string.ascii_lowercase
chars = (
string.ascii_lowercase + string.digits
if alphanumeric
else string.ascii_lowercase
)
return "".join(random.choices(chars, k=length))
@staticmethod
def gen_id() -> str:
"""
生成 Statsig ID
Returns:
Base64 编码的 ID
"""
# 读取配置
dynamic = get_config("grok.dynamic_statsig", True)
if not dynamic:
return "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
# 随机格式
if random.choice([True, False]):
rand = StatsigService._rand(5, alphanumeric=True)
message = f"e:TypeError: Cannot read properties of null (reading 'children['{rand}']')"
else:
rand = StatsigService._rand(10)
message = f"e:TypeError: Cannot read properties of undefined (reading '{rand}')"
message = (
f"e:TypeError: Cannot read properties of undefined (reading '{rand}')"
)
return base64.b64encode(message.encode()).decode()
+26 -30
View File
@@ -6,12 +6,11 @@ import asyncio
import uuid
from typing import Dict
import orjson
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
from app.core.exceptions import UpstreamException
from app.services.grok.statsig import StatsigService
from app.services.grok.retry import retry_on_status
@@ -23,6 +22,7 @@ DEFAULT_MAX_CONCURRENT = 25
_USAGE_SEMAPHORE = asyncio.Semaphore(DEFAULT_MAX_CONCURRENT)
_USAGE_SEM_VALUE = DEFAULT_MAX_CONCURRENT
def _get_usage_semaphore() -> asyncio.Semaphore:
global _USAGE_SEMAPHORE, _USAGE_SEM_VALUE
value = get_config("performance.usage_max_concurrent", DEFAULT_MAX_CONCURRENT)
@@ -39,11 +39,11 @@ def _get_usage_semaphore() -> asyncio.Semaphore:
class UsageService:
"""用量查询服务"""
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) -> dict:
"""构建请求头"""
headers = {
@@ -68,33 +68,33 @@ class UsageService:
"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) -> dict:
"""构建代理配置"""
return {"http": self.proxy, "https": self.proxy} if self.proxy else None
async def get(self, token: str, model_name: str = "grok-4-1-thinking-1129") -> Dict:
"""
获取速率限制信息
Args:
token: 认证 Token
model_name: 模型名称
Returns:
响应数据
Raises:
UpstreamException: 当获取失败且重试耗尽时
"""
@@ -104,16 +104,13 @@ class UsageService:
if isinstance(e, UpstreamException) and e.details:
return e.details.get("status")
return None
# 定义实际的请求函数
async def do_request():
try:
headers = self._build_headers(token)
payload = {
"requestKind": "DEFAULT",
"modelName": model_name
}
payload = {"requestKind": "DEFAULT", "modelName": model_name}
async with AsyncSession() as session:
response = await session.post(
LIMITS_API,
@@ -121,40 +118,39 @@ class UsageService:
json=payload,
impersonate=BROWSER,
timeout=self.timeout,
proxies=self._build_proxies()
proxies=self._build_proxies(),
)
if response.status_code == 200:
data = response.json()
remaining = data.get('remainingTokens', 0)
remaining = data.get("remainingTokens", 0)
logger.info(f"Usage: quota {remaining} remaining")
return data
logger.error(f"Usage failed: {response.status_code}")
raise UpstreamException(
message=f"Failed to get usage stats: {response.status_code}",
details={"status": response.status_code}
details={"status": response.status_code},
)
except Exception as e:
if isinstance(e, UpstreamException):
raise
logger.error(f"Usage error: {e}")
raise UpstreamException(
message=f"Usage service error: {str(e)}",
details={"error": str(e)}
details={"error": str(e)},
)
# 带重试的执行
try:
result = await retry_on_status(
do_request,
extract_status=extract_status
do_request, extract_status=extract_status
)
return result
except Exception as e:
except Exception:
# 最后一次失败已经被记录
raise
+4 -7
View File
@@ -6,7 +6,7 @@ from app.services.token.models import (
TokenPoolStats,
EffortType,
DEFAULT_QUOTA,
EFFORT_COST
EFFORT_COST,
)
from app.services.token.pool import TokenPool
from app.services.token.manager import TokenManager, get_token_manager
@@ -16,20 +16,17 @@ from app.services.token.scheduler import TokenRefreshScheduler, get_scheduler
__all__ = [
# Models
"TokenInfo",
"TokenStatus",
"TokenPoolStats",
"EffortType",
"TokenStatus",
"TokenPoolStats",
"EffortType",
"DEFAULT_QUOTA",
"EFFORT_COST",
# Core
"TokenPool",
"TokenManager",
# API
"TokenService",
"get_token_manager",
# Scheduler
"TokenRefreshScheduler",
"get_scheduler",
+21 -20
View File
@@ -8,67 +8,68 @@ from app.services.token.models import TokenInfo, TokenStatus, TokenPoolStats
class TokenPool:
"""Token 池(管理一组 Token)"""
def __init__(self, name: str):
self.name = name
self._tokens: Dict[str, TokenInfo] = {}
def add(self, token: TokenInfo):
"""添加 Token"""
self._tokens[token.token] = token
def remove(self, token_str: str) -> bool:
"""删除 Token"""
if token_str in self._tokens:
del self._tokens[token_str]
return True
return False
def get(self, token_str: str) -> Optional[TokenInfo]:
"""获取 Token"""
return self._tokens.get(token_str)
def select(self) -> Optional[TokenInfo]:
"""
选择一个可用 Token
策略:
策略:
1. 选择 active 状态且有配额的 token
2. 优先选择剩余额度最多的
3. 如果额度相同,随机选择(避免并发冲突)
"""
# 选择 token
available = [
t for t in self._tokens.values()
t
for t in self._tokens.values()
if t.status == TokenStatus.ACTIVE and t.quota > 0
]
if not available:
return None
# 找到最大额度
max_quota = max(t.quota for t in available)
# 筛选最大额度
candidates = [t for t in available if t.quota == max_quota]
# 随机选择
return random.choice(candidates)
def count(self) -> int:
"""Token 数量"""
return len(self._tokens)
def list(self) -> List[TokenInfo]:
"""获取所有 Token"""
return list(self._tokens.values())
def get_stats(self) -> TokenPoolStats:
"""获取池统计信息"""
stats = TokenPoolStats(total=len(self._tokens))
for token in self._tokens.values():
stats.total_quota += token.quota
if token.status == TokenStatus.ACTIVE:
stats.active += 1
elif token.status == TokenStatus.DISABLED:
@@ -77,16 +78,16 @@ class TokenPool:
stats.expired += 1
elif token.status == TokenStatus.COOLING:
stats.cooling += 1
if stats.total > 0:
stats.avg_quota = stats.total_quota / stats.total
return stats
def _rebuild_index(self):
"""重建索引(预留接口,用于加载时调用)"""
pass
def __iter__(self) -> Iterator[TokenInfo]:
return iter(self._tokens.values())
+12 -10
View File
@@ -10,17 +10,17 @@ from app.services.token.manager import get_token_manager
class TokenRefreshScheduler:
"""Token 自动刷新调度器"""
def __init__(self, interval_hours: int = 8):
self.interval_hours = interval_hours
self.interval_seconds = interval_hours * 3600
self._task: Optional[asyncio.Task] = None
self._running = False
async def _refresh_loop(self):
"""刷新循环"""
logger.info(f"Scheduler: started (interval: {self.interval_hours}h)")
while self._running:
try:
await asyncio.sleep(self.interval_seconds)
@@ -31,7 +31,9 @@ class TokenRefreshScheduler:
if isinstance(storage, RedisStorage):
# Redis: non-blocking lock to avoid multi-worker duplication
lock_key = "grok2api:lock:token_refresh"
lock = storage.redis.lock(lock_key, timeout=self.interval_seconds + 60, blocking_timeout=0)
lock = storage.redis.lock(
lock_key, timeout=self.interval_seconds + 60, blocking_timeout=0
)
lock_acquired = await lock.acquire(blocking=False)
else:
try:
@@ -48,7 +50,7 @@ class TokenRefreshScheduler:
logger.info("Scheduler: starting token refresh...")
manager = await get_token_manager()
result = await manager.refresh_cooling_tokens()
logger.info(
f"Scheduler: refresh completed - "
f"checked={result['checked']}, "
@@ -62,27 +64,27 @@ class TokenRefreshScheduler:
await lock.release()
except Exception:
pass
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Scheduler: refresh error - {e}")
def start(self):
"""启动调度器"""
if self._running:
logger.warning("Scheduler: already running")
return
self._running = True
self._task = asyncio.create_task(self._refresh_loop())
logger.info("Scheduler: enabled")
def stop(self):
"""停止调度器"""
if not self._running:
return
self._running = False
if self._task:
self._task.cancel()
+29 -31
View File
@@ -9,143 +9,141 @@ from app.services.token.models import TokenInfo, EffortType
class TokenService:
"""
Token 服务外观
提供简化的 API,隐藏内部实现细节
"""
@staticmethod
async def get_token(pool_name: str = "ssoBasic") -> Optional[str]:
"""
获取可用 Token
Args:
pool_name: Token 池名称
Returns:
Token 字符串(不含 sso= 前缀)或 None
"""
manager = await get_token_manager()
return manager.get_token(pool_name)
@staticmethod
async def consume(token: str, effort: EffortType = EffortType.LOW) -> bool:
"""
消耗 Token 配额(本地预估)
Args:
token: Token 字符串
effort: 消耗力度
Returns:
是否成功
"""
manager = await get_token_manager()
return await manager.consume(token, effort)
@staticmethod
async def sync_usage(
token: str,
model: str,
effort: EffortType = EffortType.LOW
token: str, model: str, effort: EffortType = EffortType.LOW
) -> bool:
"""
同步 Token 使用量(优先 API,降级本地)
Args:
token: Token 字符串
model: 模型名称
effort: 降级时的消耗力度
Returns:
是否成功
"""
manager = await get_token_manager()
return await manager.sync_usage(token, model, effort)
@staticmethod
async def record_fail(token: str, status_code: int = 401, reason: str = "") -> bool:
"""
记录 Token 失败
Args:
token: Token 字符串
status_code: HTTP 状态码
reason: 失败原因
Returns:
是否成功
"""
manager = await get_token_manager()
return await manager.record_fail(token, status_code, reason)
@staticmethod
async def add_token(token: str, pool_name: str = "ssoBasic") -> bool:
"""
添加 Token
Args:
token: Token 字符串
pool: Token 池名称
Returns:
是否成功
"""
manager = await get_token_manager()
return await manager.add(token, pool_name)
@staticmethod
async def remove_token(token: str) -> bool:
"""
删除 Token
Args:
token: Token 字符串
Returns:
是否成功
"""
manager = await get_token_manager()
return await manager.remove(token)
@staticmethod
async def reset_token(token: str) -> bool:
"""
重置单个 Token
Args:
token: Token 字符串
Returns:
是否成功
"""
manager = await get_token_manager()
return await manager.reset_token(token)
@staticmethod
async def reset_all():
"""重置所有 Token"""
manager = await get_token_manager()
await manager.reset_all()
@staticmethod
async def get_stats() -> Dict[str, dict]:
"""
获取统计信息
Returns:
各池的统计信息
"""
manager = await get_token_manager()
return manager.get_stats()
@staticmethod
async def list_tokens(pool_name: str = "ssoBasic") -> List[TokenInfo]:
"""
获取指定池的所有 Token
Args:
pool_name: Token 池名称
Returns:
Token 列表
"""
+46 -41
View File
@@ -8,8 +8,6 @@ from contextlib import asynccontextmanager
import os
import platform
import sys
import time
import uuid
from pathlib import Path
from dotenv import load_dotenv
@@ -18,58 +16,59 @@ env_file = Path(__file__).parent / ".env"
if env_file.exists():
load_dotenv(env_file)
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi import Depends
from fastapi import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastapi import Depends # noqa: E402
from app.core.auth import verify_api_key
from app.core.config import get_config
from app.core.logger import logger, setup_logging
from app.core.exceptions import register_exception_handlers
from app.core.response_middleware import ResponseLoggerMiddleware
from app.api.v1.chat import router as chat_router
from app.api.v1.image import router as image_router
from app.api.v1.files import router as files_router
from app.api.v1.models import router as models_router
from app.services.token import get_scheduler
from app.core.auth import verify_api_key # noqa: E402
from app.core.config import get_config # noqa: E402
from app.core.logger import logger, setup_logging # noqa: E402
from app.core.exceptions import register_exception_handlers # noqa: E402
from app.core.response_middleware import ResponseLoggerMiddleware # noqa: E402
from app.api.v1.chat import router as chat_router # noqa: E402
from app.api.v1.image import router as image_router # noqa: E402
from app.api.v1.files import router as files_router # noqa: E402
from app.api.v1.models import router as models_router # noqa: E402
from app.services.token import get_scheduler # noqa: E402
# 初始化日志
setup_logging(
level=os.getenv("LOG_LEVEL", "INFO"),
json_console=False,
file_logging=True
level=os.getenv("LOG_LEVEL", "INFO"), json_console=False, file_logging=True
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 1. 加载配置
from app.core.config import config
await config.load()
# 2. 启动服务显示
logger.info("Starting Grok2API...")
logger.info(f"Platform: {platform.system()} {platform.release()}")
logger.info(f"Python: {sys.version.split()[0]}")
# 3. 启动 Token 刷新调度器
refresh_enabled = get_config("token.auto_refresh", True)
if refresh_enabled:
interval = get_config("token.refresh_interval_hours", 8)
scheduler = get_scheduler(interval)
scheduler.start()
logger.info("Application startup complete.")
yield
# 关闭
logger.info("Shutting down Grok2API...")
from app.core.storage import StorageFactory
if StorageFactory._instance:
await StorageFactory._instance.close()
if refresh_enabled:
scheduler = get_scheduler()
scheduler.stop()
@@ -81,7 +80,7 @@ def create_app() -> FastAPI:
title="Grok2API",
lifespan=lifespan,
)
# CORS 配置
app.add_middleware(
CORSMiddleware,
@@ -90,30 +89,37 @@ def create_app() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
# 请求日志和 ID 中间件
from app.core.response_middleware import ResponseLoggerMiddleware
app.add_middleware(ResponseLoggerMiddleware)
# 注册异常处理器
register_exception_handlers(app)
# 注册路由
app.include_router(chat_router, prefix="/v1", dependencies=[Depends(verify_api_key)])
app.include_router(image_router, prefix="/v1", dependencies=[Depends(verify_api_key)])
app.include_router(models_router, prefix="/v1", dependencies=[Depends(verify_api_key)])
app.include_router(
chat_router, prefix="/v1", dependencies=[Depends(verify_api_key)]
)
app.include_router(
image_router, prefix="/v1", dependencies=[Depends(verify_api_key)]
)
app.include_router(
models_router, prefix="/v1", dependencies=[Depends(verify_api_key)]
)
app.include_router(files_router, prefix="/v1/files")
# 静态文件服务
from fastapi.staticfiles import StaticFiles
static_dir = Path(__file__).parent / "app" / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=static_dir), name="static")
# 注册管理路由
from app.api.v1.admin import router as admin_router
app.include_router(admin_router)
return app
@@ -122,15 +128,14 @@ app = create_app()
if __name__ == "__main__":
import uvicorn
import warnings
host = os.getenv("SERVER_HOST", "0.0.0.0")
port = int(os.getenv("SERVER_PORT", "8000"))
workers = int(os.getenv("SERVER_WORKERS", "1"))
# 平台检查
is_windows = platform.system() == "Windows"
# 自动降级
if is_windows and workers > 1:
logger.warning(
@@ -138,11 +143,11 @@ if __name__ == "__main__":
"Using single worker instead."
)
workers = 1
uvicorn.run(
"main:app",
host=host,
port=port,
workers=workers,
log_level=os.getenv("LOG_LEVEL", "INFO").lower()
log_level=os.getenv("LOG_LEVEL", "INFO").lower(),
)