Merge pull request #139 from chenyme/optimize
refactor: optimize code framework and project configuration
This commit is contained in:
35 files changed
+2881
-3151
No files matched your search
+107
-193
@@ -6,6 +6,8 @@ from app.core.config import config, get_config
|
||||
from app.core.batch_tasks import create_task, get_task, expire_task
|
||||
from app.core.storage import get_storage, LocalStorage, RedisStorage, SQLStorage
|
||||
from app.core.exceptions import AppException
|
||||
from app.services.token.manager import get_token_manager
|
||||
from app.services.grok.utils.batch import run_in_batches
|
||||
import os
|
||||
from pathlib import Path
|
||||
import aiofiles
|
||||
@@ -13,12 +15,44 @@ import asyncio
|
||||
import orjson
|
||||
from app.core.logger import logger
|
||||
from app.services.grok.services.voice import VoiceService
|
||||
from app.services.token import get_token_manager
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).parent.parent.parent / "static"
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).parent.parent.parent / "static"
|
||||
|
||||
def _collect_tokens(data: dict) -> list[str]:
|
||||
"""从请求数据中收集 token 列表"""
|
||||
tokens = []
|
||||
if isinstance(data.get("token"), str) and data["token"].strip():
|
||||
tokens.append(data["token"].strip())
|
||||
if isinstance(data.get("tokens"), list):
|
||||
tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()])
|
||||
return tokens
|
||||
|
||||
|
||||
def _truncate_tokens(
|
||||
tokens: list[str], max_tokens: int, operation: str = "operation"
|
||||
) -> tuple[list[str], bool, int]:
|
||||
"""去重并截断 token 列表,返回 (unique_tokens, truncated, original_count)"""
|
||||
unique_tokens = list(dict.fromkeys(tokens))
|
||||
original_count = len(unique_tokens)
|
||||
truncated = False
|
||||
|
||||
if len(unique_tokens) > max_tokens:
|
||||
unique_tokens = unique_tokens[:max_tokens]
|
||||
truncated = True
|
||||
logger.warning(
|
||||
f"{operation}: truncated from {original_count} to {max_tokens} tokens"
|
||||
)
|
||||
|
||||
return unique_tokens, truncated, original_count
|
||||
|
||||
|
||||
def _mask_token(token: str) -> str:
|
||||
"""掩码 token 显示"""
|
||||
return f"{token[:8]}...{token[-8:]}" if len(token) > 20 else token
|
||||
|
||||
|
||||
async def render_template(filename: str):
|
||||
@@ -208,10 +242,9 @@ async def update_config_api(data: dict):
|
||||
@router.get("/api/v1/admin/storage", dependencies=[Depends(verify_api_key)])
|
||||
async def get_storage_info():
|
||||
"""获取当前存储模式"""
|
||||
storage_type = os.getenv("SERVER_STORAGE_TYPE", "local").lower()
|
||||
logger.info(f"Storage type: {storage_type}")
|
||||
storage_type = os.getenv("SERVER_STORAGE_TYPE", "").lower()
|
||||
if not storage_type:
|
||||
storage_type = str(get_config("storage.type", "")).lower()
|
||||
storage_type = str(get_config("storage.type")).lower()
|
||||
if not storage_type:
|
||||
storage = get_storage()
|
||||
if isinstance(storage, LocalStorage):
|
||||
@@ -219,12 +252,13 @@ async def get_storage_info():
|
||||
elif isinstance(storage, RedisStorage):
|
||||
storage_type = "redis"
|
||||
elif isinstance(storage, SQLStorage):
|
||||
if storage.dialect in ("mysql", "mariadb"):
|
||||
storage_type = "mysql"
|
||||
elif storage.dialect in ("postgres", "postgresql", "pgsql"):
|
||||
storage_type = "pgsql"
|
||||
else:
|
||||
storage_type = storage.dialect
|
||||
storage_type = {
|
||||
"mysql": "mysql",
|
||||
"mariadb": "mysql",
|
||||
"postgres": "pgsql",
|
||||
"postgresql": "pgsql",
|
||||
"pgsql": "pgsql",
|
||||
}.get(storage.dialect, storage.dialect)
|
||||
return {"type": storage_type or "local"}
|
||||
|
||||
|
||||
@@ -283,7 +317,9 @@ async def update_tokens_api(data: dict):
|
||||
if isinstance(raw_token, str) and raw_token.startswith("sso="):
|
||||
token_data["token"] = raw_token[4:]
|
||||
|
||||
base = existing_map.get(pool_name, {}).get(token_data.get("token"), {})
|
||||
base = existing_map.get(pool_name, {}).get(
|
||||
token_data.get("token"), {}
|
||||
)
|
||||
merged = dict(base)
|
||||
merged.update(token_data)
|
||||
if merged.get("tags") is None:
|
||||
@@ -309,42 +345,22 @@ async def update_tokens_api(data: dict):
|
||||
@router.post("/api/v1/admin/tokens/refresh", dependencies=[Depends(verify_api_key)])
|
||||
async def refresh_tokens_api(data: dict):
|
||||
"""刷新 Token 状态"""
|
||||
from app.services.token.manager import get_token_manager
|
||||
from app.services.grok.utils.batch import run_in_batches
|
||||
|
||||
try:
|
||||
mgr = await get_token_manager()
|
||||
tokens = []
|
||||
if "token" in data:
|
||||
tokens.append(data["token"])
|
||||
if "tokens" in data and isinstance(data["tokens"], list):
|
||||
tokens.extend(data["tokens"])
|
||||
tokens = _collect_tokens(data)
|
||||
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail="No tokens provided")
|
||||
|
||||
# 去重并保持顺序
|
||||
unique_tokens = list(dict.fromkeys(tokens))
|
||||
|
||||
# 最大数量限制
|
||||
max_tokens = get_config("performance.usage_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
|
||||
truncated = False
|
||||
original_count = len(unique_tokens)
|
||||
if len(unique_tokens) > max_tokens:
|
||||
unique_tokens = unique_tokens[:max_tokens]
|
||||
truncated = True
|
||||
logger.warning(
|
||||
f"Usage refresh: truncated from {original_count} to {max_tokens} tokens"
|
||||
)
|
||||
# 去重并截断
|
||||
max_tokens = int(get_config("performance.usage_max_tokens"))
|
||||
unique_tokens, truncated, original_count = _truncate_tokens(
|
||||
tokens, max_tokens, "Usage refresh"
|
||||
)
|
||||
|
||||
# 批量执行配置
|
||||
max_concurrent = get_config("performance.usage_max_concurrent", 25)
|
||||
batch_size = get_config("performance.usage_batch_size", 50)
|
||||
max_concurrent = get_config("performance.usage_max_concurrent")
|
||||
batch_size = get_config("performance.usage_batch_size")
|
||||
|
||||
async def _refresh_one(t):
|
||||
return await mgr.sync_usage(
|
||||
@@ -380,38 +396,20 @@ async def refresh_tokens_api(data: dict):
|
||||
)
|
||||
async def refresh_tokens_api_async(data: dict):
|
||||
"""刷新 Token 状态(异步批量 + SSE 进度)"""
|
||||
from app.services.token.manager import get_token_manager
|
||||
from app.services.grok.utils.batch import run_in_batches
|
||||
|
||||
mgr = await get_token_manager()
|
||||
tokens: list[str] = []
|
||||
if isinstance(data.get("token"), str) and data["token"].strip():
|
||||
tokens.append(data["token"].strip())
|
||||
if isinstance(data.get("tokens"), list):
|
||||
tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()])
|
||||
tokens = _collect_tokens(data)
|
||||
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail="No tokens provided")
|
||||
|
||||
unique_tokens = list(dict.fromkeys(tokens))
|
||||
# 去重并截断
|
||||
max_tokens = int(get_config("performance.usage_max_tokens"))
|
||||
unique_tokens, truncated, original_count = _truncate_tokens(
|
||||
tokens, max_tokens, "Usage refresh"
|
||||
)
|
||||
|
||||
max_tokens = get_config("performance.usage_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
|
||||
truncated = False
|
||||
original_count = len(unique_tokens)
|
||||
if len(unique_tokens) > max_tokens:
|
||||
unique_tokens = unique_tokens[:max_tokens]
|
||||
truncated = True
|
||||
logger.warning(
|
||||
f"Usage refresh: truncated from {original_count} to {max_tokens} tokens"
|
||||
)
|
||||
|
||||
max_concurrent = get_config("performance.usage_max_concurrent", 25)
|
||||
batch_size = get_config("performance.usage_batch_size", 50)
|
||||
max_concurrent = get_config("performance.usage_max_concurrent")
|
||||
batch_size = get_config("performance.usage_batch_size")
|
||||
|
||||
task = create_task(len(unique_tokens))
|
||||
|
||||
@@ -485,19 +483,13 @@ async def refresh_tokens_api_async(data: dict):
|
||||
async def enable_nsfw_api(data: dict):
|
||||
"""批量开启 NSFW (Unhinged) 模式"""
|
||||
from app.services.grok.services.nsfw import NSFWService
|
||||
from app.services.grok.utils.batch import run_in_batches
|
||||
from app.services.token.manager import get_token_manager
|
||||
|
||||
try:
|
||||
mgr = await get_token_manager()
|
||||
nsfw_service = NSFWService()
|
||||
|
||||
# 收集 token 列表
|
||||
tokens: list[str] = []
|
||||
if isinstance(data.get("token"), str) and data["token"].strip():
|
||||
tokens.append(data["token"].strip())
|
||||
if isinstance(data.get("tokens"), list):
|
||||
tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()])
|
||||
tokens = _collect_tokens(data)
|
||||
|
||||
# 若未指定,则使用所有 pool 中的 token
|
||||
if not tokens:
|
||||
@@ -511,28 +503,15 @@ async def enable_nsfw_api(data: dict):
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail="No tokens available")
|
||||
|
||||
# 去重并保持顺序
|
||||
unique_tokens = list(dict.fromkeys(tokens))
|
||||
|
||||
# 限制最大数量(超出时截取前 N 个)
|
||||
max_tokens = get_config("performance.nsfw_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
|
||||
truncated = False
|
||||
original_count = len(unique_tokens)
|
||||
if len(unique_tokens) > max_tokens:
|
||||
unique_tokens = unique_tokens[:max_tokens]
|
||||
truncated = True
|
||||
logger.warning(
|
||||
f"NSFW enable: truncated from {original_count} to {max_tokens} tokens"
|
||||
)
|
||||
# 去重并截断
|
||||
max_tokens = int(get_config("performance.nsfw_max_tokens"))
|
||||
unique_tokens, truncated, original_count = _truncate_tokens(
|
||||
tokens, max_tokens, "NSFW enable"
|
||||
)
|
||||
|
||||
# 批量执行配置
|
||||
max_concurrent = get_config("performance.nsfw_max_concurrent", 10)
|
||||
batch_size = get_config("performance.nsfw_batch_size", 50)
|
||||
max_concurrent = get_config("performance.nsfw_max_concurrent")
|
||||
batch_size = get_config("performance.nsfw_batch_size")
|
||||
|
||||
# 定义 worker
|
||||
async def _enable(token: str):
|
||||
@@ -559,7 +538,7 @@ async def enable_nsfw_api(data: dict):
|
||||
fail_count = 0
|
||||
|
||||
for token, res in raw_results.items():
|
||||
masked = f"{token[:8]}...{token[-8:]}" if len(token) > 20 else token
|
||||
masked = _mask_token(token)
|
||||
if res.get("ok") and res.get("data", {}).get("success"):
|
||||
ok_count += 1
|
||||
results[masked] = res.get("data", {})
|
||||
@@ -598,17 +577,11 @@ async def enable_nsfw_api(data: dict):
|
||||
async def enable_nsfw_api_async(data: dict):
|
||||
"""批量开启 NSFW (Unhinged) 模式(异步批量 + SSE 进度)"""
|
||||
from app.services.grok.services.nsfw import NSFWService
|
||||
from app.services.grok.utils.batch import run_in_batches
|
||||
from app.services.token.manager import get_token_manager
|
||||
|
||||
mgr = await get_token_manager()
|
||||
nsfw_service = NSFWService()
|
||||
|
||||
tokens: list[str] = []
|
||||
if isinstance(data.get("token"), str) and data["token"].strip():
|
||||
tokens.append(data["token"].strip())
|
||||
if isinstance(data.get("tokens"), list):
|
||||
tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()])
|
||||
tokens = _collect_tokens(data)
|
||||
|
||||
if not tokens:
|
||||
for pool_name, pool in mgr.pools.items():
|
||||
@@ -619,25 +592,14 @@ async def enable_nsfw_api_async(data: dict):
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail="No tokens available")
|
||||
|
||||
unique_tokens = list(dict.fromkeys(tokens))
|
||||
# 去重并截断
|
||||
max_tokens = int(get_config("performance.nsfw_max_tokens"))
|
||||
unique_tokens, truncated, original_count = _truncate_tokens(
|
||||
tokens, max_tokens, "NSFW enable"
|
||||
)
|
||||
|
||||
max_tokens = get_config("performance.nsfw_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
|
||||
truncated = False
|
||||
original_count = len(unique_tokens)
|
||||
if len(unique_tokens) > max_tokens:
|
||||
unique_tokens = unique_tokens[:max_tokens]
|
||||
truncated = True
|
||||
logger.warning(
|
||||
f"NSFW enable: truncated from {original_count} to {max_tokens} tokens"
|
||||
)
|
||||
|
||||
max_concurrent = get_config("performance.nsfw_max_concurrent", 10)
|
||||
batch_size = get_config("performance.nsfw_batch_size", 50)
|
||||
max_concurrent = get_config("performance.nsfw_max_concurrent")
|
||||
batch_size = get_config("performance.nsfw_batch_size")
|
||||
|
||||
task = create_task(len(unique_tokens))
|
||||
|
||||
@@ -772,24 +734,9 @@ async def get_cache_stats_api(request: Request):
|
||||
}
|
||||
online_details = []
|
||||
account_map = {a["token"]: a for a in accounts}
|
||||
max_concurrent = get_config("performance.assets_max_concurrent", 25)
|
||||
batch_size = get_config("performance.assets_batch_size", 10)
|
||||
try:
|
||||
max_concurrent = int(max_concurrent)
|
||||
except Exception:
|
||||
max_concurrent = 25
|
||||
try:
|
||||
batch_size = int(batch_size)
|
||||
except Exception:
|
||||
batch_size = 10
|
||||
max_concurrent = max(1, max_concurrent)
|
||||
batch_size = max(1, batch_size)
|
||||
|
||||
max_tokens = get_config("performance.assets_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
max_concurrent = max(1, int(get_config("performance.assets_max_concurrent")))
|
||||
batch_size = max(1, int(get_config("performance.assets_batch_size")))
|
||||
max_tokens = int(get_config("performance.assets_max_tokens"))
|
||||
|
||||
truncated = False
|
||||
original_count = 0
|
||||
@@ -832,11 +779,9 @@ async def get_cache_stats_api(request: Request):
|
||||
}
|
||||
|
||||
if selected_tokens:
|
||||
selected_tokens = list(dict.fromkeys(selected_tokens))
|
||||
original_count = len(selected_tokens)
|
||||
if len(selected_tokens) > max_tokens:
|
||||
selected_tokens = selected_tokens[:max_tokens]
|
||||
truncated = True
|
||||
selected_tokens, truncated, original_count = _truncate_tokens(
|
||||
selected_tokens, max_tokens, "Assets fetch"
|
||||
)
|
||||
total = 0
|
||||
raw_results = await run_in_batches(
|
||||
selected_tokens,
|
||||
@@ -1004,22 +949,13 @@ async def load_online_cache_api_async(data: dict):
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No tokens provided")
|
||||
|
||||
selected_tokens = list(dict.fromkeys(selected_tokens))
|
||||
max_tokens = int(get_config("performance.assets_max_tokens"))
|
||||
selected_tokens, truncated, original_count = _truncate_tokens(
|
||||
selected_tokens, max_tokens, "Assets load"
|
||||
)
|
||||
|
||||
max_tokens = get_config("performance.assets_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
|
||||
truncated = False
|
||||
original_count = len(selected_tokens)
|
||||
if len(selected_tokens) > max_tokens:
|
||||
selected_tokens = selected_tokens[:max_tokens]
|
||||
truncated = True
|
||||
|
||||
max_concurrent = get_config("performance.assets_max_concurrent", 25)
|
||||
batch_size = get_config("performance.assets_batch_size", 10)
|
||||
max_concurrent = get_config("performance.assets_max_concurrent")
|
||||
batch_size = get_config("performance.assets_batch_size")
|
||||
|
||||
task = create_task(len(selected_tokens))
|
||||
|
||||
@@ -1193,30 +1129,16 @@ async def clear_online_cache_api(data: dict):
|
||||
token_list = list(dict.fromkeys(token_list))
|
||||
|
||||
# 最大数量限制
|
||||
max_tokens = get_config("performance.assets_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
truncated = False
|
||||
original_count = len(token_list)
|
||||
if len(token_list) > max_tokens:
|
||||
token_list = token_list[:max_tokens]
|
||||
truncated = True
|
||||
max_tokens = int(get_config("performance.assets_max_tokens"))
|
||||
token_list, truncated, original_count = _truncate_tokens(
|
||||
token_list, max_tokens, "Clear online cache"
|
||||
)
|
||||
|
||||
results = {}
|
||||
max_concurrent = get_config("performance.assets_max_concurrent", 25)
|
||||
batch_size = get_config("performance.assets_batch_size", 10)
|
||||
try:
|
||||
max_concurrent = int(max_concurrent)
|
||||
except Exception:
|
||||
max_concurrent = 25
|
||||
try:
|
||||
batch_size = int(batch_size)
|
||||
except Exception:
|
||||
batch_size = 10
|
||||
max_concurrent = max(1, max_concurrent)
|
||||
batch_size = max(1, batch_size)
|
||||
max_concurrent = max(
|
||||
1, int(get_config("performance.assets_max_concurrent"))
|
||||
)
|
||||
batch_size = max(1, int(get_config("performance.assets_batch_size")))
|
||||
|
||||
async def _clear_one(t: str):
|
||||
try:
|
||||
@@ -1279,21 +1201,13 @@ async def clear_online_cache_api_async(data: dict):
|
||||
if not token_list:
|
||||
raise HTTPException(status_code=400, detail="No tokens provided")
|
||||
|
||||
token_list = list(dict.fromkeys(token_list))
|
||||
max_tokens = int(get_config("performance.assets_max_tokens"))
|
||||
token_list, truncated, original_count = _truncate_tokens(
|
||||
token_list, max_tokens, "Clear online cache async"
|
||||
)
|
||||
|
||||
max_tokens = get_config("performance.assets_max_tokens", 1000)
|
||||
try:
|
||||
max_tokens = int(max_tokens)
|
||||
except Exception:
|
||||
max_tokens = 1000
|
||||
truncated = False
|
||||
original_count = len(token_list)
|
||||
if len(token_list) > max_tokens:
|
||||
token_list = token_list[:max_tokens]
|
||||
truncated = True
|
||||
|
||||
max_concurrent = get_config("performance.assets_max_concurrent", 25)
|
||||
batch_size = get_config("performance.assets_batch_size", 10)
|
||||
max_concurrent = get_config("performance.assets_max_concurrent")
|
||||
batch_size = get_config("performance.assets_batch_size")
|
||||
|
||||
task = create_task(len(token_list))
|
||||
|
||||
|
||||
+89
-172
@@ -7,6 +7,7 @@ import base64
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
@@ -19,7 +20,7 @@ from app.services.grok.services.image import image_service
|
||||
from app.services.grok.services.assets import UploadService
|
||||
from app.services.grok.services.media import VideoService
|
||||
from app.services.grok.models.model import ModelService
|
||||
from app.services.grok.processors.processor import (
|
||||
from app.services.grok.processors import (
|
||||
ImageStreamProcessor,
|
||||
ImageCollectProcessor,
|
||||
ImageWSStreamProcessor,
|
||||
@@ -47,7 +48,6 @@ class ImageGenerationRequest(BaseModel):
|
||||
stream: Optional[bool] = Field(False, description="是否流式输出")
|
||||
|
||||
|
||||
|
||||
class ImageEditRequest(BaseModel):
|
||||
"""图片编辑请求 - OpenAI 兼容"""
|
||||
|
||||
@@ -62,7 +62,6 @@ class ImageEditRequest(BaseModel):
|
||||
stream: Optional[bool] = Field(False, description="是否流式输出")
|
||||
|
||||
|
||||
|
||||
def _validate_common_request(
|
||||
request: Union[ImageGenerationRequest, ImageEditRequest],
|
||||
*,
|
||||
@@ -93,14 +92,12 @@ def _validate_common_request(
|
||||
# WS 流式仅支持 b64_json (base64 视为同义)
|
||||
if (
|
||||
request.stream
|
||||
and get_config("grok.image_ws", False)
|
||||
and get_config("image.image_ws")
|
||||
and request.response_format
|
||||
and request.response_format not in {"b64_json", "base64"}
|
||||
):
|
||||
raise ValidationException(
|
||||
message=(
|
||||
"Streaming with image_ws only supports response_format=b64_json/base64"
|
||||
),
|
||||
message="Streaming with image_ws only supports response_format=b64_json/base64",
|
||||
param="response_format",
|
||||
code="invalid_response_format",
|
||||
)
|
||||
@@ -140,25 +137,22 @@ def validate_generation_request(request: ImageGenerationRequest):
|
||||
|
||||
|
||||
def resolve_response_format(response_format: Optional[str]) -> str:
|
||||
fmt = response_format or get_config("app.image_format", "url")
|
||||
"""解析响应格式"""
|
||||
fmt = response_format or get_config("app.image_format")
|
||||
if isinstance(fmt, str):
|
||||
fmt = fmt.lower()
|
||||
if fmt in ("b64_json", "base64", "url"):
|
||||
return fmt
|
||||
allowed_formats = {"b64_json", "base64", "url"}
|
||||
raise ValidationException(
|
||||
message=f"response_format must be one of {sorted(allowed_formats)}",
|
||||
message="response_format must be one of b64_json, base64, url",
|
||||
param="response_format",
|
||||
code="invalid_response_format",
|
||||
)
|
||||
|
||||
|
||||
def response_field_name(response_format: str) -> str:
|
||||
if response_format == "url":
|
||||
return "url"
|
||||
if response_format == "base64":
|
||||
return "base64"
|
||||
return "b64_json"
|
||||
"""获取响应字段名"""
|
||||
return {"url": "url", "base64": "base64"}.get(response_format, "b64_json")
|
||||
|
||||
|
||||
def resolve_aspect_ratio(size: str) -> str:
|
||||
@@ -189,9 +183,7 @@ def validate_edit_request(request: ImageEditRequest, images: List[UploadFile]):
|
||||
"""验证图片编辑请求参数"""
|
||||
if request.model != "grok-imagine-1.0-edit":
|
||||
raise ValidationException(
|
||||
message=(
|
||||
"The model `grok-imagine-1.0-edit` is required for image edits."
|
||||
),
|
||||
message=("The model `grok-imagine-1.0-edit` is required for image edits."),
|
||||
param="model",
|
||||
code="model_not_supported",
|
||||
)
|
||||
@@ -210,6 +202,52 @@ def validate_edit_request(request: ImageEditRequest, images: List[UploadFile]):
|
||||
)
|
||||
|
||||
|
||||
def _get_effort(model_info) -> EffortType:
|
||||
"""获取模型消耗级别"""
|
||||
return (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
|
||||
|
||||
async def _wrap_stream_with_usage(stream, token_mgr, token, model_info):
|
||||
"""包装流式响应,成功完成时记录使用"""
|
||||
success = False
|
||||
try:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
success = True
|
||||
finally:
|
||||
if success:
|
||||
try:
|
||||
await token_mgr.consume(token, _get_effort(model_info))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to consume token: {e}")
|
||||
|
||||
|
||||
async def _get_token(model: str):
|
||||
"""获取可用 token"""
|
||||
token_mgr = await get_token_manager()
|
||||
await token_mgr.reload_if_stale()
|
||||
|
||||
token = None
|
||||
for pool_name in ModelService.pool_candidates_for_model(model):
|
||||
token = token_mgr.get_token(pool_name)
|
||||
if token:
|
||||
break
|
||||
|
||||
if not token:
|
||||
raise AppException(
|
||||
message="No available tokens. Please try again later.",
|
||||
error_type=ErrorType.RATE_LIMIT.value,
|
||||
code="rate_limit_exceeded",
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
return token_mgr, token
|
||||
|
||||
|
||||
async def call_grok(
|
||||
token_mgr,
|
||||
token: str,
|
||||
@@ -232,7 +270,6 @@ async def call_grok(
|
||||
file_attachments=file_attachments,
|
||||
)
|
||||
|
||||
# 收集图片
|
||||
processor = ImageCollectProcessor(
|
||||
model_info.model_id, token, response_format=response_format
|
||||
)
|
||||
@@ -244,15 +281,9 @@ async def call_grok(
|
||||
logger.error(f"Grok image call failed: {e}")
|
||||
return []
|
||||
finally:
|
||||
# 只在成功时记录使用,失败时不扣费(避免清零 fail_count)
|
||||
if success:
|
||||
try:
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
await token_mgr.consume(token, _get_effort(model_info))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to consume token: {e}")
|
||||
|
||||
@@ -286,40 +317,16 @@ async def create_image(request: ImageGenerationRequest):
|
||||
response_format = resolve_response_format(request.response_format)
|
||||
response_field = response_field_name(response_format)
|
||||
|
||||
# 获取 token
|
||||
try:
|
||||
token_mgr = await get_token_manager()
|
||||
await token_mgr.reload_if_stale()
|
||||
token = None
|
||||
for pool_name in ModelService.pool_candidates_for_model(request.model):
|
||||
token = token_mgr.get_token(pool_name)
|
||||
if token:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get token: {e}")
|
||||
raise AppException(
|
||||
message="Internal service error obtaining token",
|
||||
error_type=ErrorType.SERVER.value,
|
||||
code="internal_error",
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise AppException(
|
||||
message="No available tokens. Please try again later.",
|
||||
error_type=ErrorType.RATE_LIMIT.value,
|
||||
code="rate_limit_exceeded",
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
# 获取模型信息
|
||||
# 获取 token 和模型信息
|
||||
token_mgr, token = await _get_token(request.model)
|
||||
model_info = ModelService.get(request.model)
|
||||
use_ws = bool(get_config("grok.image_ws", False))
|
||||
use_ws = bool(get_config("image.image_ws"))
|
||||
|
||||
# 流式模式
|
||||
if request.stream:
|
||||
if use_ws:
|
||||
aspect_ratio = resolve_aspect_ratio(request.size)
|
||||
enable_nsfw = bool(get_config("grok.image_ws_nsfw", True))
|
||||
enable_nsfw = bool(get_config("image.image_ws_nsfw"))
|
||||
upstream = image_service.stream(
|
||||
token=token,
|
||||
prompt=request.prompt,
|
||||
@@ -335,26 +342,10 @@ async def create_image(request: ImageGenerationRequest):
|
||||
size=request.size,
|
||||
)
|
||||
|
||||
async def _wrap_stream(stream):
|
||||
success = False
|
||||
try:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
success = True
|
||||
finally:
|
||||
if success:
|
||||
try:
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to consume token: {e}")
|
||||
|
||||
return StreamingResponse(
|
||||
_wrap_stream(processor.process(upstream)),
|
||||
_wrap_stream_with_usage(
|
||||
processor.process(upstream), token_mgr, token, model_info
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
@@ -372,28 +363,10 @@ async def create_image(request: ImageGenerationRequest):
|
||||
model_info.model_id, token, n=request.n, response_format=response_format
|
||||
)
|
||||
|
||||
# 包装流式响应,在成功完成时记录使用
|
||||
async def _wrap_stream(stream):
|
||||
success = False
|
||||
try:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
success = True
|
||||
finally:
|
||||
# 只在成功完成时扣费
|
||||
if success:
|
||||
try:
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to consume token: {e}")
|
||||
|
||||
return StreamingResponse(
|
||||
_wrap_stream(processor.process(response)),
|
||||
_wrap_stream_with_usage(
|
||||
processor.process(response), token_mgr, token, model_info
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
@@ -404,7 +377,7 @@ async def create_image(request: ImageGenerationRequest):
|
||||
usage_override = None
|
||||
if use_ws:
|
||||
aspect_ratio = resolve_aspect_ratio(request.size)
|
||||
enable_nsfw = bool(get_config("grok.image_ws_nsfw", True))
|
||||
enable_nsfw = bool(get_config("image.image_ws_nsfw"))
|
||||
all_images = []
|
||||
seen = set()
|
||||
expected_per_call = 6
|
||||
@@ -447,12 +420,7 @@ async def create_image(request: ImageGenerationRequest):
|
||||
if len(all_images) >= n:
|
||||
break
|
||||
try:
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
await token_mgr.consume(token, _get_effort(model_info))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to consume token: {e}")
|
||||
usage_override = {
|
||||
@@ -505,16 +473,11 @@ async def create_image(request: ImageGenerationRequest):
|
||||
selected_images.append("error")
|
||||
|
||||
# 构建响应
|
||||
import time
|
||||
|
||||
data = [{response_field: img} for img in selected_images]
|
||||
|
||||
usage = usage_override or {
|
||||
"total_tokens": 0
|
||||
* len([img for img in selected_images if img != "error"]),
|
||||
"total_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
* len([img for img in selected_images if img != "error"]),
|
||||
"output_tokens": 0,
|
||||
"input_tokens_details": {"text_tokens": 0, "image_tokens": 0},
|
||||
}
|
||||
|
||||
@@ -527,7 +490,6 @@ async def create_image(request: ImageGenerationRequest):
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/images/edits")
|
||||
async def edit_image(
|
||||
prompt: str = Form(...),
|
||||
@@ -622,32 +584,8 @@ async def edit_image(
|
||||
b64 = base64.b64encode(content).decode()
|
||||
images.append(f"data:{mime};base64,{b64}")
|
||||
|
||||
# 获取 token
|
||||
try:
|
||||
token_mgr = await get_token_manager()
|
||||
await token_mgr.reload_if_stale()
|
||||
token = None
|
||||
for pool_name in ModelService.pool_candidates_for_model(edit_request.model):
|
||||
token = token_mgr.get_token(pool_name)
|
||||
if token:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get token: {e}")
|
||||
raise AppException(
|
||||
message="Internal service error obtaining token",
|
||||
error_type=ErrorType.SERVER.value,
|
||||
code="internal_error",
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise AppException(
|
||||
message="No available tokens. Please try again later.",
|
||||
error_type=ErrorType.RATE_LIMIT.value,
|
||||
code="rate_limit_exceeded",
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
# 获取模型信息
|
||||
# 获取 token 和模型信息
|
||||
token_mgr, token = await _get_token(edit_request.model)
|
||||
model_info = ModelService.get(edit_request.model)
|
||||
|
||||
# 上传图片
|
||||
@@ -660,9 +598,7 @@ async def edit_image(
|
||||
if file_uri.startswith("http"):
|
||||
image_urls.append(file_uri)
|
||||
else:
|
||||
image_urls.append(
|
||||
f"https://assets.grok.com/{file_uri.lstrip('/')}"
|
||||
)
|
||||
image_urls.append(f"https://assets.grok.com/{file_uri.lstrip('/')}")
|
||||
finally:
|
||||
await upload_service.close()
|
||||
|
||||
@@ -704,12 +640,12 @@ async def edit_image(
|
||||
}
|
||||
|
||||
if parent_post_id:
|
||||
model_config_override["modelMap"]["imageEditModelConfig"][
|
||||
"parentPostId"
|
||||
] = parent_post_id
|
||||
model_config_override["modelMap"]["imageEditModelConfig"]["parentPostId"] = (
|
||||
parent_post_id
|
||||
)
|
||||
|
||||
raw_payload = {
|
||||
"temporary": bool(get_config("grok.temporary", True)),
|
||||
"temporary": bool(get_config("chat.temporary")),
|
||||
"modelName": model_info.grok_model,
|
||||
"message": edit_request.prompt,
|
||||
"enableImageGeneration": True,
|
||||
@@ -741,29 +677,16 @@ async def edit_image(
|
||||
)
|
||||
|
||||
processor = ImageStreamProcessor(
|
||||
model_info.model_id, token, n=edit_request.n, response_format=response_format
|
||||
model_info.model_id,
|
||||
token,
|
||||
n=edit_request.n,
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
async def _wrap_stream(stream):
|
||||
success = False
|
||||
try:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
success = True
|
||||
finally:
|
||||
if success:
|
||||
try:
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to consume token: {e}")
|
||||
|
||||
return StreamingResponse(
|
||||
_wrap_stream(processor.process(response)),
|
||||
_wrap_stream_with_usage(
|
||||
processor.process(response), token_mgr, token, model_info
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
@@ -790,10 +713,7 @@ async def edit_image(
|
||||
if calls_needed == 1:
|
||||
all_images = await _call_edit()
|
||||
else:
|
||||
tasks = [
|
||||
_call_edit()
|
||||
for _ in range(calls_needed)
|
||||
]
|
||||
tasks = [_call_edit() for _ in range(calls_needed)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
all_images = []
|
||||
@@ -803,6 +723,7 @@ async def edit_image(
|
||||
elif isinstance(result, list):
|
||||
all_images.extend(result)
|
||||
|
||||
# 选择图片
|
||||
if len(all_images) >= n:
|
||||
selected_images = random.sample(all_images, n)
|
||||
else:
|
||||
@@ -810,8 +731,6 @@ async def edit_image(
|
||||
while len(selected_images) < n:
|
||||
selected_images.append("error")
|
||||
|
||||
import time
|
||||
|
||||
data = [{response_field: img} for img in selected_images]
|
||||
|
||||
return JSONResponse(
|
||||
@@ -819,11 +738,9 @@ async def edit_image(
|
||||
"created": int(time.time()),
|
||||
"data": data,
|
||||
"usage": {
|
||||
"total_tokens": 0
|
||||
* len([img for img in selected_images if img != "error"]),
|
||||
"total_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
* len([img for img in selected_images if img != "error"]),
|
||||
"output_tokens": 0,
|
||||
"input_tokens_details": {"text_tokens": 0, "image_tokens": 0},
|
||||
},
|
||||
}
|
||||
|
||||
+5
-2
@@ -8,6 +8,9 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.config import get_config
|
||||
|
||||
DEFAULT_API_KEY = ""
|
||||
DEFAULT_APP_KEY = "grok2api"
|
||||
|
||||
# 定义 Bearer Scheme
|
||||
security = HTTPBearer(
|
||||
auto_error=False,
|
||||
@@ -22,7 +25,7 @@ def get_admin_api_key() -> str:
|
||||
|
||||
为空时表示不启用后台接口认证。
|
||||
"""
|
||||
api_key = get_config("app.api_key", "")
|
||||
api_key = get_config("app.api_key", DEFAULT_API_KEY)
|
||||
return api_key or ""
|
||||
|
||||
|
||||
@@ -63,7 +66,7 @@ async def verify_app_key(
|
||||
|
||||
app_key 必须配置,否则拒绝登录。
|
||||
"""
|
||||
app_key = get_config("app.app_key", "")
|
||||
app_key = get_config("app.app_key", DEFAULT_APP_KEY)
|
||||
|
||||
if not app_key:
|
||||
raise HTTPException(
|
||||
|
||||
+103
-3
@@ -32,6 +32,78 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any
|
||||
return result
|
||||
|
||||
|
||||
def _migrate_deprecated_config(
|
||||
config: Dict[str, Any], valid_sections: set
|
||||
) -> tuple[Dict[str, Any], set]:
|
||||
"""
|
||||
迁移废弃的配置节到新配置结构
|
||||
|
||||
Returns:
|
||||
(迁移后的配置, 废弃的配置节集合)
|
||||
"""
|
||||
# 配置映射规则:旧配置 -> 新配置
|
||||
MIGRATION_MAP = {
|
||||
# grok.* -> 对应的新配置节
|
||||
"grok.temporary": "chat.temporary",
|
||||
"grok.disable_memory": "chat.disable_memory",
|
||||
"grok.stream": "chat.stream",
|
||||
"grok.thinking": "chat.thinking",
|
||||
"grok.dynamic_statsig": "chat.dynamic_statsig",
|
||||
"grok.filter_tags": "chat.filter_tags",
|
||||
"grok.timeout": "network.timeout",
|
||||
"grok.base_proxy_url": "network.base_proxy_url",
|
||||
"grok.asset_proxy_url": "network.asset_proxy_url",
|
||||
"grok.cf_clearance": "security.cf_clearance",
|
||||
"grok.browser": "security.browser",
|
||||
"grok.user_agent": "security.user_agent",
|
||||
"grok.max_retry": "retry.max_retry",
|
||||
"grok.retry_status_codes": "retry.retry_status_codes",
|
||||
"grok.retry_backoff_base": "retry.retry_backoff_base",
|
||||
"grok.retry_backoff_factor": "retry.retry_backoff_factor",
|
||||
"grok.retry_backoff_max": "retry.retry_backoff_max",
|
||||
"grok.retry_budget": "retry.retry_budget",
|
||||
"grok.stream_idle_timeout": "timeout.stream_idle_timeout",
|
||||
"grok.video_idle_timeout": "timeout.video_idle_timeout",
|
||||
"grok.image_ws": "image.image_ws",
|
||||
"grok.image_ws_nsfw": "image.image_ws_nsfw",
|
||||
"grok.image_ws_blocked_seconds": "image.image_ws_blocked_seconds",
|
||||
"grok.image_ws_final_min_bytes": "image.image_ws_final_min_bytes",
|
||||
"grok.image_ws_medium_min_bytes": "image.image_ws_medium_min_bytes",
|
||||
}
|
||||
|
||||
deprecated_sections = set(config.keys()) - valid_sections
|
||||
if not deprecated_sections:
|
||||
return config, set()
|
||||
|
||||
result = {k: deepcopy(v) for k, v in config.items() if k in valid_sections}
|
||||
migrated_count = 0
|
||||
|
||||
# 处理废弃配置节中的配置项
|
||||
for old_section in deprecated_sections:
|
||||
if old_section not in config or not isinstance(config[old_section], dict):
|
||||
continue
|
||||
|
||||
for old_key, old_value in config[old_section].items():
|
||||
# 查找映射规则
|
||||
old_path = f"{old_section}.{old_key}"
|
||||
new_path = MIGRATION_MAP.get(old_path)
|
||||
|
||||
if new_path:
|
||||
new_section, new_key = new_path.split(".", 1)
|
||||
# 确保新配置节存在
|
||||
if new_section not in result:
|
||||
result[new_section] = {}
|
||||
# 迁移配置项(保留用户的自定义值)
|
||||
result[new_section][new_key] = old_value
|
||||
migrated_count += 1
|
||||
logger.debug(f"Migrated config: {old_path} -> {new_path} = {old_value}")
|
||||
|
||||
if migrated_count > 0:
|
||||
logger.info(f"Migrated {migrated_count} config items from deprecated sections")
|
||||
|
||||
return result, deprecated_sections
|
||||
|
||||
|
||||
def _load_defaults() -> Dict[str, Any]:
|
||||
"""加载默认配置文件"""
|
||||
if not DEFAULT_CONFIG_FILE.exists():
|
||||
@@ -53,12 +125,19 @@ class Config:
|
||||
def __init__(self):
|
||||
self._config = {}
|
||||
self._defaults = {}
|
||||
self._code_defaults = {}
|
||||
self._defaults_loaded = False
|
||||
|
||||
def register_defaults(self, defaults: Dict[str, Any]):
|
||||
"""注册代码中定义的默认值"""
|
||||
self._code_defaults = _deep_merge(self._code_defaults, defaults)
|
||||
|
||||
def _ensure_defaults(self):
|
||||
if self._defaults_loaded:
|
||||
return
|
||||
self._defaults = _load_defaults()
|
||||
file_defaults = _load_defaults()
|
||||
# 合并文件默认值和代码默认值(代码默认值优先级更低)
|
||||
self._defaults = _deep_merge(self._code_defaults, file_defaults)
|
||||
self._defaults_loaded = True
|
||||
|
||||
async def load(self):
|
||||
@@ -84,10 +163,24 @@ class Config:
|
||||
config_data = {}
|
||||
|
||||
config_data = config_data or {}
|
||||
|
||||
# 检查是否有废弃的配置节
|
||||
valid_sections = set(self._defaults.keys())
|
||||
config_data, deprecated_sections = _migrate_deprecated_config(
|
||||
config_data, valid_sections
|
||||
)
|
||||
if deprecated_sections:
|
||||
logger.info(
|
||||
f"Cleaned deprecated config sections: {deprecated_sections}"
|
||||
)
|
||||
|
||||
merged = _deep_merge(self._defaults, config_data)
|
||||
|
||||
# 自动回填缺失配置到存储
|
||||
should_persist = (not from_remote) or (merged != config_data)
|
||||
# 或迁移了配置后需要更新
|
||||
should_persist = (
|
||||
(not from_remote) or (merged != config_data) or deprecated_sections
|
||||
)
|
||||
if should_persist:
|
||||
async with storage.acquire_lock("config_save", timeout=10):
|
||||
await storage.save_config(merged)
|
||||
@@ -95,6 +188,8 @@ class Config:
|
||||
logger.info(
|
||||
f"Initialized remote storage ({storage.__class__.__name__}) with config baseline."
|
||||
)
|
||||
if deprecated_sections:
|
||||
logger.info("Configuration automatically migrated and cleaned.")
|
||||
|
||||
self._config = merged
|
||||
except Exception as e:
|
||||
@@ -140,4 +235,9 @@ def get_config(key: str, default: Any = None) -> Any:
|
||||
return config.get(key, default)
|
||||
|
||||
|
||||
__all__ = ["Config", "config", "get_config"]
|
||||
def register_defaults(defaults: Dict[str, Any]):
|
||||
"""注册默认配置"""
|
||||
config.register_defaults(defaults)
|
||||
|
||||
|
||||
__all__ = ["Config", "config", "get_config", "register_defaults"]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Grok 服务默认配置
|
||||
|
||||
此文件定义所有 Grok 相关服务的默认值,会在应用启动时注册到配置系统中。
|
||||
"""
|
||||
|
||||
# Grok 服务默认配置
|
||||
GROK_DEFAULTS = {
|
||||
"app": {
|
||||
"app_url": "",
|
||||
"app_key": "grok2api",
|
||||
"api_key": "",
|
||||
"image_format": "url",
|
||||
"video_format": "html",
|
||||
},
|
||||
"network": {
|
||||
"timeout": 120,
|
||||
"base_proxy_url": "",
|
||||
"asset_proxy_url": "",
|
||||
},
|
||||
"security": {
|
||||
"cf_clearance": "",
|
||||
"browser": "chrome136",
|
||||
"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",
|
||||
},
|
||||
"chat": {
|
||||
"temporary": True,
|
||||
"disable_memory": True,
|
||||
"stream": True,
|
||||
"thinking": False,
|
||||
"dynamic_statsig": True,
|
||||
"filter_tags": ["grok:render", "xaiartifact", "xai:tool_usage_card"],
|
||||
},
|
||||
"retry": {
|
||||
"max_retry": 3,
|
||||
"retry_status_codes": [401, 429, 403],
|
||||
"retry_backoff_base": 0.5,
|
||||
"retry_backoff_factor": 2.0,
|
||||
"retry_backoff_max": 30.0,
|
||||
"retry_budget": 90.0,
|
||||
},
|
||||
"timeout": {
|
||||
"stream_idle_timeout": 45.0,
|
||||
"video_idle_timeout": 90.0,
|
||||
},
|
||||
"image": {
|
||||
"image_ws": True,
|
||||
"image_ws_nsfw": True,
|
||||
"image_ws_blocked_seconds": 15,
|
||||
"image_ws_final_min_bytes": 100000,
|
||||
"image_ws_medium_min_bytes": 30000,
|
||||
},
|
||||
"token": {
|
||||
"auto_refresh": True,
|
||||
"refresh_interval_hours": 8,
|
||||
"super_refresh_interval_hours": 2,
|
||||
"fail_threshold": 5,
|
||||
"save_delay_ms": 500,
|
||||
"reload_interval_sec": 30,
|
||||
},
|
||||
"cache": {
|
||||
"enable_auto_clean": True,
|
||||
"limit_mb": 1024,
|
||||
},
|
||||
"performance": {
|
||||
"assets_max_concurrent": 25,
|
||||
"assets_delete_batch_size": 10,
|
||||
"assets_batch_size": 10,
|
||||
"assets_max_tokens": 1000,
|
||||
"media_max_concurrent": 50,
|
||||
"usage_max_concurrent": 25,
|
||||
"usage_batch_size": 50,
|
||||
"usage_max_tokens": 1000,
|
||||
"nsfw_max_concurrent": 10,
|
||||
"nsfw_batch_size": 50,
|
||||
"nsfw_max_tokens": 1000,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_grok_defaults():
|
||||
"""获取 Grok 默认配置"""
|
||||
return GROK_DEFAULTS
|
||||
|
||||
|
||||
__all__ = ["GROK_DEFAULTS", "get_grok_defaults"]
|
||||
@@ -112,7 +112,6 @@ class ModelService:
|
||||
cost=Cost.HIGH,
|
||||
display_name="GROK-4.1-EXPERT",
|
||||
),
|
||||
|
||||
ModelInfo(
|
||||
model_id="grok-4.1-thinking",
|
||||
grok_model="grok-4-1-thinking-1129",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
OpenAI 响应格式处理器
|
||||
"""
|
||||
|
||||
from .base import BaseProcessor, StreamIdleTimeoutError
|
||||
from .chat_processors import StreamProcessor, CollectProcessor
|
||||
from .video_processors import VideoStreamProcessor, VideoCollectProcessor
|
||||
from .image_processors import ImageStreamProcessor, ImageCollectProcessor
|
||||
from .image_ws_processors import ImageWSStreamProcessor, ImageWSCollectProcessor
|
||||
|
||||
__all__ = [
|
||||
"BaseProcessor",
|
||||
"StreamIdleTimeoutError",
|
||||
"StreamProcessor",
|
||||
"CollectProcessor",
|
||||
"VideoStreamProcessor",
|
||||
"VideoCollectProcessor",
|
||||
"ImageStreamProcessor",
|
||||
"ImageCollectProcessor",
|
||||
"ImageWSStreamProcessor",
|
||||
"ImageWSCollectProcessor",
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
响应处理器基类和通用工具
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Optional, AsyncIterable, List, TypeVar
|
||||
|
||||
from app.core.config import get_config
|
||||
from app.core.logger import logger
|
||||
from app.services.grok.services.assets import DownloadService
|
||||
|
||||
|
||||
ASSET_URL = "https://assets.grok.com/"
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _is_http2_stream_error(e: Exception) -> bool:
|
||||
"""检查是否为 HTTP/2 流错误"""
|
||||
err_str = str(e).lower()
|
||||
return "http/2" in err_str or "curl: (92)" in err_str or "stream" in err_str
|
||||
|
||||
|
||||
def _normalize_stream_line(line: Any) -> Optional[str]:
|
||||
"""规范化流式响应行,兼容 SSE data 前缀与空行"""
|
||||
if line is None:
|
||||
return None
|
||||
if isinstance(line, (bytes, bytearray)):
|
||||
text = line.decode("utf-8", errors="ignore")
|
||||
else:
|
||||
text = str(line)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith("data:"):
|
||||
text = text[5:].strip()
|
||||
if text == "[DONE]":
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _collect_image_urls(obj: Any) -> List[str]:
|
||||
"""递归收集响应中的图片 URL"""
|
||||
urls: List[str] = []
|
||||
seen = set()
|
||||
|
||||
def add(url: str):
|
||||
if not url or url in seen:
|
||||
return
|
||||
seen.add(url)
|
||||
urls.append(url)
|
||||
|
||||
def walk(value: Any):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if key in {"generatedImageUrls", "imageUrls", "imageURLs"}:
|
||||
if isinstance(item, list):
|
||||
for url in item:
|
||||
if isinstance(url, str):
|
||||
add(url)
|
||||
elif isinstance(item, str):
|
||||
add(item)
|
||||
continue
|
||||
walk(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
walk(item)
|
||||
|
||||
walk(obj)
|
||||
return urls
|
||||
|
||||
|
||||
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 = ""
|
||||
) -> AsyncGenerator[T, None]:
|
||||
"""
|
||||
包装异步迭代器,添加空闲超时检测
|
||||
|
||||
Args:
|
||||
iterable: 原始异步迭代器
|
||||
idle_timeout: 空闲超时时间(秒),0 表示禁用
|
||||
model: 模型名称(用于日志)
|
||||
"""
|
||||
if idle_timeout <= 0:
|
||||
async for item in iterable:
|
||||
yield item
|
||||
return
|
||||
|
||||
iterator = iterable.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
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},
|
||||
)
|
||||
raise StreamIdleTimeoutError(idle_timeout)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
|
||||
class BaseProcessor:
|
||||
"""基础处理器"""
|
||||
|
||||
def __init__(self, model: str, token: str = ""):
|
||||
self.model = model
|
||||
self.token = token
|
||||
self.created = int(time.time())
|
||||
self.app_url = get_config("app.app_url")
|
||||
self._dl_service: Optional[DownloadService] = None
|
||||
|
||||
def _get_dl(self) -> DownloadService:
|
||||
"""获取下载服务实例(复用)"""
|
||||
if self._dl_service is None:
|
||||
self._dl_service = DownloadService()
|
||||
return self._dl_service
|
||||
|
||||
async def close(self):
|
||||
"""释放下载服务资源"""
|
||||
if self._dl_service:
|
||||
await self._dl_service.close()
|
||||
self._dl_service = None
|
||||
|
||||
async def process_url(self, path: str, media_type: str = "image") -> str:
|
||||
"""处理资产 URL"""
|
||||
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}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseProcessor",
|
||||
"StreamIdleTimeoutError",
|
||||
"_with_idle_timeout",
|
||||
"_normalize_stream_line",
|
||||
"_collect_image_urls",
|
||||
"_is_http2_stream_error",
|
||||
]
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
聊天响应处理器
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import re
|
||||
from typing import Any, AsyncGenerator, AsyncIterable
|
||||
|
||||
import orjson
|
||||
from curl_cffi.requests.errors import RequestsError
|
||||
|
||||
from app.core.config import get_config
|
||||
from app.core.logger import logger
|
||||
from app.core.exceptions import UpstreamException
|
||||
from .base import (
|
||||
BaseProcessor,
|
||||
StreamIdleTimeoutError,
|
||||
_with_idle_timeout,
|
||||
_normalize_stream_line,
|
||||
_collect_image_urls,
|
||||
_is_http2_stream_error,
|
||||
)
|
||||
|
||||
|
||||
class StreamProcessor(BaseProcessor):
|
||||
"""流式响应处理器"""
|
||||
|
||||
def __init__(self, model: str, token: str = "", think: bool = None):
|
||||
super().__init__(model, token)
|
||||
self.response_id: str = None
|
||||
self.fingerprint: str = ""
|
||||
self.think_opened: bool = False
|
||||
self.role_sent: bool = False
|
||||
self.filter_tags = get_config("chat.filter_tags")
|
||||
self.image_format = get_config("app.image_format")
|
||||
self._tag_buffer: str = ""
|
||||
self._in_filter_tag: bool = False
|
||||
|
||||
if think is None:
|
||||
self.show_think = get_config("chat.thinking")
|
||||
else:
|
||||
self.show_think = think
|
||||
|
||||
def _filter_token(self, token: str) -> str:
|
||||
"""过滤 token 中的特殊标签(如 <grok:render>...</grok:render>),支持跨 token 的标签过滤"""
|
||||
if not self.filter_tags:
|
||||
return token
|
||||
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(token):
|
||||
char = token[i]
|
||||
|
||||
if self._in_filter_tag:
|
||||
self._tag_buffer += char
|
||||
if char == ">":
|
||||
if "/>" in self._tag_buffer:
|
||||
self._in_filter_tag = False
|
||||
self._tag_buffer = ""
|
||||
else:
|
||||
for tag in self.filter_tags:
|
||||
if f"</{tag}>" in self._tag_buffer:
|
||||
self._in_filter_tag = False
|
||||
self._tag_buffer = ""
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if char == "<":
|
||||
remaining = token[i:]
|
||||
tag_started = False
|
||||
for tag in self.filter_tags:
|
||||
if remaining.startswith(f"<{tag}"):
|
||||
tag_started = True
|
||||
break
|
||||
if len(remaining) < len(tag) + 1:
|
||||
for j in range(1, len(remaining) + 1):
|
||||
if f"<{tag}".startswith(remaining[:j]):
|
||||
tag_started = True
|
||||
break
|
||||
|
||||
if tag_started:
|
||||
self._in_filter_tag = True
|
||||
self._tag_buffer = char
|
||||
i += 1
|
||||
continue
|
||||
|
||||
result.append(char)
|
||||
i += 1
|
||||
|
||||
return "".join(result)
|
||||
|
||||
def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
|
||||
"""构建 SSE 响应"""
|
||||
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,
|
||||
"choices": [
|
||||
{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}
|
||||
],
|
||||
}
|
||||
return f"data: {orjson.dumps(chunk).decode()}\n\n"
|
||||
|
||||
async def process(
|
||||
self, response: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""处理流式响应"""
|
||||
idle_timeout = get_config("timeout.stream_idle_timeout")
|
||||
|
||||
try:
|
||||
async for line in _with_idle_timeout(response, idle_timeout, self.model):
|
||||
line = _normalize_stream_line(line)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
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
|
||||
|
||||
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"
|
||||
)
|
||||
continue
|
||||
|
||||
# modelResponse
|
||||
if mr := resp.get("modelResponse"):
|
||||
if self.think_opened and self.show_think:
|
||||
if msg := mr.get("message"):
|
||||
yield self._sse(msg + "\n")
|
||||
yield self._sse("</think>\n")
|
||||
self.think_opened = False
|
||||
|
||||
# 处理生成的图片
|
||||
for url in _collect_image_urls(mr):
|
||||
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"
|
||||
)
|
||||
if base64_data:
|
||||
yield self._sse(f"\n")
|
||||
else:
|
||||
final_url = await self.process_url(url, "image")
|
||||
yield self._sse(f"\n")
|
||||
else:
|
||||
final_url = await self.process_url(url, "image")
|
||||
yield self._sse(f"\n")
|
||||
|
||||
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")
|
||||
yield "data: [DONE]\n\n"
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Stream cancelled by client", extra={"model": self.model})
|
||||
except StreamIdleTimeoutError as e:
|
||||
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,
|
||||
},
|
||||
)
|
||||
except RequestsError as e:
|
||||
if _is_http2_stream_error(e):
|
||||
logger.warning(f"HTTP/2 stream error: {e}", extra={"model": self.model})
|
||||
raise UpstreamException(
|
||||
message="Upstream connection closed unexpectedly",
|
||||
status_code=502,
|
||||
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)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Stream processing error: {e}",
|
||||
extra={"model": self.model, "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
await self.close()
|
||||
|
||||
|
||||
class CollectProcessor(BaseProcessor):
|
||||
"""非流式响应处理器"""
|
||||
|
||||
def __init__(self, model: str, token: str = ""):
|
||||
super().__init__(model, token)
|
||||
self.image_format = get_config("app.image_format")
|
||||
self.filter_tags = get_config("chat.filter_tags")
|
||||
|
||||
def _filter_content(self, content: str) -> str:
|
||||
"""过滤内容中的特殊标签"""
|
||||
if not content or not self.filter_tags:
|
||||
return content
|
||||
|
||||
result = content
|
||||
for tag in self.filter_tags:
|
||||
pattern = rf"<{re.escape(tag)}[^>]*>.*?</{re.escape(tag)}>|<{re.escape(tag)}[^>]*/>"
|
||||
result = re.sub(pattern, "", result, flags=re.DOTALL)
|
||||
|
||||
return result
|
||||
|
||||
async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]:
|
||||
"""处理并收集完整响应"""
|
||||
response_id = ""
|
||||
fingerprint = ""
|
||||
content = ""
|
||||
idle_timeout = get_config("timeout.stream_idle_timeout")
|
||||
|
||||
try:
|
||||
async for line in _with_idle_timeout(response, idle_timeout, self.model):
|
||||
line = _normalize_stream_line(line)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
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 := _collect_image_urls(mr):
|
||||
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"
|
||||
)
|
||||
if base64_data:
|
||||
content += f"\n"
|
||||
else:
|
||||
final_url = await self.process_url(url, "image")
|
||||
content += f"\n"
|
||||
else:
|
||||
final_url = await self.process_url(url, "image")
|
||||
content += f"\n"
|
||||
|
||||
if (
|
||||
(meta := mr.get("metadata", {}))
|
||||
.get("llm_info", {})
|
||||
.get("modelHash")
|
||||
):
|
||||
fingerprint = meta["llm_info"]["modelHash"]
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Collect cancelled by client", extra={"model": self.model})
|
||||
except StreamIdleTimeoutError as e:
|
||||
logger.warning(f"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 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__},
|
||||
)
|
||||
finally:
|
||||
await self.close()
|
||||
|
||||
content = self._filter_content(content)
|
||||
|
||||
return {
|
||||
"id": response_id,
|
||||
"object": "chat.completion",
|
||||
"created": self.created,
|
||||
"model": self.model,
|
||||
"system_fingerprint": fingerprint,
|
||||
"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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["StreamProcessor", "CollectProcessor"]
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
图片生成响应处理器(HTTP)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from typing import AsyncGenerator, AsyncIterable, List
|
||||
|
||||
import orjson
|
||||
from curl_cffi.requests.errors import RequestsError
|
||||
|
||||
from app.core.config import get_config
|
||||
from app.core.logger import logger
|
||||
from app.core.exceptions import UpstreamException
|
||||
from .base import (
|
||||
BaseProcessor,
|
||||
StreamIdleTimeoutError,
|
||||
_with_idle_timeout,
|
||||
_normalize_stream_line,
|
||||
_collect_image_urls,
|
||||
_is_http2_stream_error,
|
||||
)
|
||||
|
||||
|
||||
class ImageStreamProcessor(BaseProcessor):
|
||||
"""图片生成流式响应处理器"""
|
||||
|
||||
def __init__(
|
||||
self, model: str, token: str = "", n: int = 1, response_format: str = "b64_json"
|
||||
):
|
||||
super().__init__(model, token)
|
||||
self.partial_index = 0
|
||||
self.n = n
|
||||
self.target_index = random.randint(0, 1) if n == 1 else None
|
||||
self.response_format = response_format
|
||||
if response_format == "url":
|
||||
self.response_field = "url"
|
||||
elif response_format == "base64":
|
||||
self.response_field = "base64"
|
||||
else:
|
||||
self.response_field = "b64_json"
|
||||
|
||||
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]:
|
||||
"""处理流式响应"""
|
||||
final_images = []
|
||||
idle_timeout = get_config("timeout.stream_idle_timeout")
|
||||
|
||||
try:
|
||||
async for line in _with_idle_timeout(response, idle_timeout, self.model):
|
||||
line = _normalize_stream_line(line)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
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",
|
||||
self.response_field: "",
|
||||
"index": out_index,
|
||||
"progress": progress,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# modelResponse
|
||||
if mr := resp.get("modelResponse"):
|
||||
if urls := _collect_image_urls(mr):
|
||||
for url in urls:
|
||||
if self.response_format == "url":
|
||||
processed = await self.process_url(url, "image")
|
||||
if processed:
|
||||
final_images.append(processed)
|
||||
continue
|
||||
dl_service = self._get_dl()
|
||||
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
|
||||
final_images.append(b64)
|
||||
continue
|
||||
|
||||
for index, b64 in enumerate(final_images):
|
||||
if self.n == 1:
|
||||
if index != self.target_index:
|
||||
continue
|
||||
out_index = 0
|
||||
else:
|
||||
out_index = index
|
||||
|
||||
yield self._sse(
|
||||
"image_generation.completed",
|
||||
{
|
||||
"type": "image_generation.completed",
|
||||
self.response_field: b64,
|
||||
"index": out_index,
|
||||
"usage": {
|
||||
"total_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"input_tokens_details": {
|
||||
"text_tokens": 0,
|
||||
"image_tokens": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
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,
|
||||
},
|
||||
)
|
||||
except RequestsError as e:
|
||||
if _is_http2_stream_error(e):
|
||||
logger.warning(f"HTTP/2 stream error in image: {e}")
|
||||
raise UpstreamException(
|
||||
message="Upstream connection closed unexpectedly",
|
||||
status_code=502,
|
||||
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)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Image stream processing error: {e}",
|
||||
extra={"error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
await self.close()
|
||||
|
||||
|
||||
class ImageCollectProcessor(BaseProcessor):
|
||||
"""图片生成非流式响应处理器"""
|
||||
|
||||
def __init__(self, model: str, token: str = "", response_format: str = "b64_json"):
|
||||
super().__init__(model, token)
|
||||
self.response_format = response_format
|
||||
|
||||
async def process(self, response: AsyncIterable[bytes]) -> List[str]:
|
||||
"""处理并收集图片"""
|
||||
images = []
|
||||
idle_timeout = get_config("timeout.stream_idle_timeout")
|
||||
|
||||
try:
|
||||
async for line in _with_idle_timeout(response, idle_timeout, self.model):
|
||||
line = _normalize_stream_line(line)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = orjson.loads(line)
|
||||
except orjson.JSONDecodeError:
|
||||
continue
|
||||
|
||||
resp = data.get("result", {}).get("response", {})
|
||||
|
||||
if mr := resp.get("modelResponse"):
|
||||
if urls := _collect_image_urls(mr):
|
||||
for url in urls:
|
||||
if self.response_format == "url":
|
||||
processed = await self.process_url(url, "image")
|
||||
if processed:
|
||||
images.append(processed)
|
||||
continue
|
||||
dl_service = self._get_dl()
|
||||
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:
|
||||
logger.warning(f"Image collect idle timeout: {e}")
|
||||
except RequestsError as e:
|
||||
if _is_http2_stream_error(e):
|
||||
logger.warning(f"HTTP/2 stream error in image collect: {e}")
|
||||
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__},
|
||||
)
|
||||
finally:
|
||||
await self.close()
|
||||
|
||||
return images
|
||||
|
||||
|
||||
__all__ = ["ImageStreamProcessor", "ImageCollectProcessor"]
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
图片生成响应处理器(WebSocket)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, AsyncIterable, List, Dict, Optional
|
||||
|
||||
import orjson
|
||||
|
||||
from app.core.config import get_config
|
||||
from app.core.logger import logger
|
||||
from app.core.exceptions import UpstreamException
|
||||
from .base import BaseProcessor
|
||||
|
||||
|
||||
class ImageWSBaseProcessor(BaseProcessor):
|
||||
"""WebSocket 图片处理基类"""
|
||||
|
||||
def __init__(self, model: str, token: str = "", response_format: str = "b64_json"):
|
||||
super().__init__(model, token)
|
||||
self.response_format = response_format
|
||||
if response_format == "url":
|
||||
self.response_field = "url"
|
||||
elif response_format == "base64":
|
||||
self.response_field = "base64"
|
||||
else:
|
||||
self.response_field = "b64_json"
|
||||
self._image_dir: Optional[Path] = None
|
||||
|
||||
def _ensure_image_dir(self) -> Path:
|
||||
if self._image_dir is None:
|
||||
base_dir = (
|
||||
Path(__file__).parent.parent.parent.parent.parent
|
||||
/ "data"
|
||||
/ "tmp"
|
||||
/ "image"
|
||||
)
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._image_dir = base_dir
|
||||
return self._image_dir
|
||||
|
||||
def _strip_base64(self, blob: str) -> str:
|
||||
if not blob:
|
||||
return ""
|
||||
if "," in blob and "base64" in blob.split(",", 1)[0]:
|
||||
return blob.split(",", 1)[1]
|
||||
return blob
|
||||
|
||||
def _filename(self, image_id: str, is_final: bool) -> str:
|
||||
ext = "jpg" if is_final else "png"
|
||||
return f"{image_id}.{ext}"
|
||||
|
||||
def _build_file_url(self, filename: str) -> str:
|
||||
app_url = get_config("app.app_url")
|
||||
if app_url:
|
||||
return f"{app_url.rstrip('/')}/v1/files/image/{filename}"
|
||||
return f"/v1/files/image/{filename}"
|
||||
|
||||
def _save_blob(self, image_id: str, blob: str, is_final: bool) -> str:
|
||||
data = self._strip_base64(blob)
|
||||
if not data:
|
||||
return ""
|
||||
image_dir = self._ensure_image_dir()
|
||||
filename = self._filename(image_id, is_final)
|
||||
filepath = image_dir / filename
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(base64.b64decode(data))
|
||||
return self._build_file_url(filename)
|
||||
|
||||
def _pick_best(self, existing: Optional[Dict], incoming: Dict) -> Dict:
|
||||
if not existing:
|
||||
return incoming
|
||||
if incoming.get("is_final") and not existing.get("is_final"):
|
||||
return incoming
|
||||
if existing.get("is_final") and not incoming.get("is_final"):
|
||||
return existing
|
||||
if incoming.get("blob_size", 0) > existing.get("blob_size", 0):
|
||||
return incoming
|
||||
return existing
|
||||
|
||||
def _to_output(self, image_id: str, item: Dict) -> str:
|
||||
try:
|
||||
if self.response_format == "url":
|
||||
return self._save_blob(
|
||||
image_id, item.get("blob", ""), item.get("is_final", False)
|
||||
)
|
||||
return self._strip_base64(item.get("blob", ""))
|
||||
except Exception as e:
|
||||
logger.warning(f"Image output failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
class ImageWSStreamProcessor(ImageWSBaseProcessor):
|
||||
"""WebSocket 图片流式响应处理器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
token: str = "",
|
||||
n: int = 1,
|
||||
response_format: str = "b64_json",
|
||||
size: str = "1024x1024",
|
||||
):
|
||||
super().__init__(model, token, "b64_json")
|
||||
self.n = n
|
||||
self.size = size
|
||||
self._target_id: Optional[str] = None
|
||||
self._index_map: Dict[str, int] = {}
|
||||
self._partial_map: Dict[str, int] = {}
|
||||
|
||||
def _assign_index(self, image_id: str) -> Optional[int]:
|
||||
if image_id in self._index_map:
|
||||
return self._index_map[image_id]
|
||||
if len(self._index_map) >= self.n:
|
||||
return None
|
||||
self._index_map[image_id] = len(self._index_map)
|
||||
return self._index_map[image_id]
|
||||
|
||||
def _sse(self, event: str, data: dict) -> str:
|
||||
return f"event: {event}\ndata: {orjson.dumps(data).decode()}\n\n"
|
||||
|
||||
async def process(self, response: AsyncIterable[dict]) -> AsyncGenerator[str, None]:
|
||||
images: Dict[str, Dict] = {}
|
||||
|
||||
async for item in response:
|
||||
if item.get("type") == "error":
|
||||
message = item.get("error") or "Upstream error"
|
||||
code = item.get("error_code") or "upstream_error"
|
||||
yield self._sse(
|
||||
"error",
|
||||
{
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "server_error",
|
||||
"code": code,
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
if item.get("type") != "image":
|
||||
continue
|
||||
|
||||
image_id = item.get("image_id")
|
||||
if not image_id:
|
||||
continue
|
||||
|
||||
if self.n == 1:
|
||||
if self._target_id is None:
|
||||
self._target_id = image_id
|
||||
index = 0 if image_id == self._target_id else None
|
||||
else:
|
||||
index = self._assign_index(image_id)
|
||||
|
||||
images[image_id] = self._pick_best(images.get(image_id), item)
|
||||
|
||||
if index is None:
|
||||
continue
|
||||
|
||||
if item.get("stage") != "final":
|
||||
partial_b64 = self._strip_base64(item.get("blob", ""))
|
||||
if not partial_b64:
|
||||
continue
|
||||
partial_index = self._partial_map.get(image_id, 0)
|
||||
if item.get("stage") == "medium":
|
||||
partial_index = max(partial_index, 1)
|
||||
self._partial_map[image_id] = partial_index
|
||||
yield self._sse(
|
||||
"image_generation.partial_image",
|
||||
{
|
||||
"type": "image_generation.partial_image",
|
||||
"b64_json": partial_b64,
|
||||
"created_at": int(time.time()),
|
||||
"size": self.size,
|
||||
"index": index,
|
||||
"partial_image_index": partial_index,
|
||||
},
|
||||
)
|
||||
|
||||
if self.n == 1:
|
||||
if self._target_id and self._target_id in images:
|
||||
selected = [(self._target_id, images[self._target_id])]
|
||||
else:
|
||||
selected = (
|
||||
[
|
||||
max(
|
||||
images.items(),
|
||||
key=lambda x: (
|
||||
x[1].get("is_final", False),
|
||||
x[1].get("blob_size", 0),
|
||||
),
|
||||
)
|
||||
]
|
||||
if images
|
||||
else []
|
||||
)
|
||||
else:
|
||||
selected = [
|
||||
(image_id, images[image_id])
|
||||
for image_id in self._index_map
|
||||
if image_id in images
|
||||
]
|
||||
|
||||
for image_id, item in selected:
|
||||
output = self._strip_base64(item.get("blob", ""))
|
||||
if not output:
|
||||
continue
|
||||
|
||||
if self.n == 1:
|
||||
index = 0
|
||||
else:
|
||||
index = self._index_map.get(image_id, 0)
|
||||
yield self._sse(
|
||||
"image_generation.completed",
|
||||
{
|
||||
"type": "image_generation.completed",
|
||||
"b64_json": output,
|
||||
"created_at": int(time.time()),
|
||||
"size": self.size,
|
||||
"index": index,
|
||||
"usage": {
|
||||
"total_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"input_tokens_details": {"text_tokens": 0, "image_tokens": 0},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ImageWSCollectProcessor(ImageWSBaseProcessor):
|
||||
"""WebSocket 图片非流式响应处理器"""
|
||||
|
||||
def __init__(
|
||||
self, model: str, token: str = "", n: int = 1, response_format: str = "b64_json"
|
||||
):
|
||||
super().__init__(model, token, response_format)
|
||||
self.n = n
|
||||
|
||||
async def process(self, response: AsyncIterable[dict]) -> List[str]:
|
||||
images: Dict[str, Dict] = {}
|
||||
|
||||
async for item in response:
|
||||
if item.get("type") == "error":
|
||||
message = item.get("error") or "Upstream error"
|
||||
raise UpstreamException(message, details=item)
|
||||
if item.get("type") != "image":
|
||||
continue
|
||||
image_id = item.get("image_id")
|
||||
if not image_id:
|
||||
continue
|
||||
images[image_id] = self._pick_best(images.get(image_id), item)
|
||||
|
||||
selected = sorted(
|
||||
images.values(),
|
||||
key=lambda x: (x.get("is_final", False), x.get("blob_size", 0)),
|
||||
reverse=True,
|
||||
)
|
||||
if self.n:
|
||||
selected = selected[: self.n]
|
||||
|
||||
results: List[str] = []
|
||||
for item in selected:
|
||||
output = self._to_output(item.get("image_id", ""), item)
|
||||
if output:
|
||||
results.append(output)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
__all__ = ["ImageWSStreamProcessor", "ImageWSCollectProcessor"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
视频响应处理器
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, AsyncGenerator, AsyncIterable, Optional
|
||||
|
||||
import orjson
|
||||
from curl_cffi.requests.errors import RequestsError
|
||||
|
||||
from app.core.config import get_config
|
||||
from app.core.logger import logger
|
||||
from app.core.exceptions import UpstreamException
|
||||
from .base import (
|
||||
BaseProcessor,
|
||||
StreamIdleTimeoutError,
|
||||
_with_idle_timeout,
|
||||
_normalize_stream_line,
|
||||
_is_http2_stream_error,
|
||||
)
|
||||
|
||||
|
||||
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 = str(get_config("app.video_format")).lower()
|
||||
|
||||
if think is None:
|
||||
self.show_think = get_config("chat.thinking")
|
||||
else:
|
||||
self.show_think = think
|
||||
|
||||
def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
|
||||
"""构建 SSE 响应"""
|
||||
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,
|
||||
"choices": [
|
||||
{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}
|
||||
],
|
||||
}
|
||||
return f"data: {orjson.dumps(chunk).decode()}\n\n"
|
||||
|
||||
def _build_video_html(self, video_url: str, thumbnail_url: str = "") -> str:
|
||||
"""构建视频 HTML 标签"""
|
||||
import html
|
||||
|
||||
safe_video_url = html.escape(video_url)
|
||||
safe_thumbnail_url = html.escape(thumbnail_url)
|
||||
poster_attr = f' poster="{safe_thumbnail_url}"' if safe_thumbnail_url else ""
|
||||
return f'''<video id="video" controls="" preload="none"{poster_attr}>
|
||||
<source id="mp4" src="{safe_video_url}" type="video/mp4">
|
||||
</video>'''
|
||||
|
||||
async def process(
|
||||
self, response: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""处理视频流式响应"""
|
||||
idle_timeout = get_config("timeout.video_idle_timeout")
|
||||
|
||||
try:
|
||||
async for line in _with_idle_timeout(response, idle_timeout, self.model):
|
||||
line = _normalize_stream_line(line)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = orjson.loads(line)
|
||||
except orjson.JSONDecodeError:
|
||||
continue
|
||||
|
||||
resp = data.get("result", {}).get("response", {})
|
||||
|
||||
if rid := resp.get("responseId"):
|
||||
self.response_id = rid
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
if self.video_format == "url":
|
||||
yield self._sse(final_video_url)
|
||||
else:
|
||||
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}
|
||||
)
|
||||
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,
|
||||
},
|
||||
)
|
||||
except RequestsError as e:
|
||||
if _is_http2_stream_error(e):
|
||||
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"},
|
||||
)
|
||||
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)},
|
||||
)
|
||||
except Exception as e:
|
||||
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 = str(get_config("app.video_format")).lower()
|
||||
|
||||
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 = ""
|
||||
content = ""
|
||||
idle_timeout = get_config("timeout.video_idle_timeout")
|
||||
|
||||
try:
|
||||
async for line in _with_idle_timeout(response, idle_timeout, self.model):
|
||||
line = _normalize_stream_line(line)
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
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"
|
||||
)
|
||||
|
||||
if self.video_format == "url":
|
||||
content = final_video_url
|
||||
else:
|
||||
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}
|
||||
)
|
||||
except StreamIdleTimeoutError as e:
|
||||
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},
|
||||
)
|
||||
else:
|
||||
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__},
|
||||
)
|
||||
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},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["VideoStreamProcessor", "VideoCollectProcessor"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@ Grok Chat 服务
|
||||
"""
|
||||
|
||||
import orjson
|
||||
from typing import Dict, List, Any, AsyncGenerator
|
||||
from typing import Dict, List, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from curl_cffi.requests import AsyncSession
|
||||
@@ -18,16 +18,14 @@ from app.core.exceptions import (
|
||||
)
|
||||
from app.services.grok.models.model import ModelService
|
||||
from app.services.grok.services.assets import UploadService
|
||||
from app.services.grok.processors.processor import StreamProcessor, CollectProcessor
|
||||
from app.services.grok.processors import StreamProcessor, CollectProcessor
|
||||
from app.services.grok.utils.retry import retry_on_status
|
||||
from app.services.grok.utils.headers import apply_statsig, build_sso_cookie
|
||||
from app.services.grok.utils.stream import wrap_stream_with_usage
|
||||
from app.services.token import get_token_manager, EffortType
|
||||
|
||||
|
||||
CHAT_API = "https://grok.com/rest/app-chat/conversations/new"
|
||||
TIMEOUT = 120
|
||||
DEFAULT_BROWSER = "chrome136"
|
||||
DEFAULT_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"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -43,56 +41,31 @@ class ChatRequest:
|
||||
class MessageExtractor:
|
||||
"""消息内容提取器"""
|
||||
|
||||
# 需要上传的类型
|
||||
UPLOAD_TYPES = {"image_url", "input_audio", "file"}
|
||||
# 视频模式不支持的类型
|
||||
VIDEO_UNSUPPORTED = {"input_audio", "file"}
|
||||
|
||||
@staticmethod
|
||||
def extract(
|
||||
messages: List[Dict[str, Any]], is_video: bool = False
|
||||
) -> tuple[str, List[tuple[str, str]]]:
|
||||
"""
|
||||
从 OpenAI 消息格式提取内容
|
||||
|
||||
Args:
|
||||
messages: OpenAI 格式消息列表
|
||||
is_video: 是否为视频模型
|
||||
|
||||
Returns:
|
||||
(text, attachments): 拼接后的文本和需要上传的附件列表
|
||||
|
||||
Raises:
|
||||
ValueError: 视频模型遇到不支持的内容类型
|
||||
"""
|
||||
"""从 OpenAI 消息格式提取内容,返回 (text, attachments)"""
|
||||
texts = []
|
||||
attachments = [] # 需要上传的附件 (URL 或 base64)
|
||||
|
||||
# 先抽取每条消息的文本,保留角色信息用于合并
|
||||
extracted: List[Dict[str, str]] = []
|
||||
attachments = []
|
||||
extracted = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
parts = []
|
||||
|
||||
# 简单字符串内容
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
parts.append(content)
|
||||
|
||||
# 列表格式内容
|
||||
elif isinstance(content, list):
|
||||
for item in content:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
# 文本类型
|
||||
if item_type == "text":
|
||||
text = item.get("text", "")
|
||||
if text.strip():
|
||||
if text := item.get("text", "").strip():
|
||||
parts.append(text)
|
||||
|
||||
# 图片类型
|
||||
elif item_type == "image_url":
|
||||
image_data = item.get("image_url", {})
|
||||
url = (
|
||||
@@ -103,7 +76,6 @@ class MessageExtractor:
|
||||
if url:
|
||||
attachments.append(("image", url))
|
||||
|
||||
# 音频类型
|
||||
elif item_type == "input_audio":
|
||||
if is_video:
|
||||
raise ValueError("视频模型不支持 input_audio 类型")
|
||||
@@ -116,12 +88,10 @@ class MessageExtractor:
|
||||
if data:
|
||||
attachments.append(("audio", data))
|
||||
|
||||
# 文件类型
|
||||
elif item_type == "file":
|
||||
if is_video:
|
||||
raise ValueError("视频模型不支持 file 类型")
|
||||
file_data = item.get("file", {})
|
||||
# file 可能是 URL 或 base64
|
||||
url = file_data.get("url", "") or file_data.get("data", "")
|
||||
if isinstance(file_data, str):
|
||||
url = file_data
|
||||
@@ -131,30 +101,22 @@ class MessageExtractor:
|
||||
if parts:
|
||||
extracted.append({"role": role, "text": "\n".join(parts)})
|
||||
|
||||
# 合并文本
|
||||
last_user_index = None
|
||||
for i in range(len(extracted) - 1, -1, -1):
|
||||
if extracted[i]["role"] == "user":
|
||||
last_user_index = i
|
||||
break
|
||||
# 找到最后一条 user 消息
|
||||
last_user_index = next(
|
||||
(
|
||||
i
|
||||
for i in range(len(extracted) - 1, -1, -1)
|
||||
if extracted[i]["role"] == "user"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
for i, item in enumerate(extracted):
|
||||
role = item["role"] or "user"
|
||||
text = item["text"]
|
||||
if i == last_user_index:
|
||||
texts.append(text)
|
||||
else:
|
||||
texts.append(f"{role}: {text}")
|
||||
texts.append(text if i == last_user_index else f"{role}: {text}")
|
||||
|
||||
# 换行拼接文本
|
||||
message = "\n\n".join(texts)
|
||||
return message, attachments
|
||||
|
||||
@staticmethod
|
||||
def extract_text_only(messages: List[Dict[str, Any]]) -> str:
|
||||
"""仅提取文本内容"""
|
||||
text, _ = MessageExtractor.extract(messages, is_video=True)
|
||||
return text
|
||||
return "\n\n".join(texts), attachments
|
||||
|
||||
|
||||
class ChatRequestBuilder:
|
||||
@@ -163,7 +125,7 @@ class ChatRequestBuilder:
|
||||
@staticmethod
|
||||
def build_headers(token: str) -> Dict[str, str]:
|
||||
"""构造请求头"""
|
||||
user_agent = get_config("grok.user_agent", DEFAULT_USER_AGENT)
|
||||
user_agent = get_config("security.user_agent")
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
@@ -200,26 +162,15 @@ class ChatRequestBuilder:
|
||||
file_attachments: List[str] = None,
|
||||
image_attachments: List[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
构造请求体
|
||||
|
||||
Args:
|
||||
message: 消息文本
|
||||
model: 模型名称
|
||||
mode: 模型模式
|
||||
file_attachments: 文件附件 ID 列表
|
||||
image_attachments: 图片附件 ID 列表(合并到 fileAttachments)
|
||||
"""
|
||||
temporary = get_config("grok.temporary", True)
|
||||
|
||||
merged_attachments: List[str] = []
|
||||
"""构造请求体"""
|
||||
merged_attachments = []
|
||||
if file_attachments:
|
||||
merged_attachments.extend(file_attachments)
|
||||
if image_attachments:
|
||||
merged_attachments.extend(image_attachments)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"temporary": temporary,
|
||||
payload = {
|
||||
"temporary": get_config("chat.temporary"),
|
||||
"modelName": model,
|
||||
"message": message,
|
||||
"fileAttachments": merged_attachments,
|
||||
@@ -227,23 +178,17 @@ class ChatRequestBuilder:
|
||||
"disableSearch": False,
|
||||
"enableImageGeneration": True,
|
||||
"returnImageBytes": False,
|
||||
"returnRawGrokInXaiRequest": False,
|
||||
"enableImageStreaming": True,
|
||||
"imageGenerationCount": 2,
|
||||
"forceConcise": False,
|
||||
"toolOverrides": {},
|
||||
"enableSideBySide": True,
|
||||
"sendFinalMetadata": True,
|
||||
"isReasoning": False,
|
||||
"disableTextFollowUps": False,
|
||||
"responseMetadata": {
|
||||
"modelConfigOverride": {"modelMap": {}},
|
||||
"requestModelDetails": {"modelId": model},
|
||||
},
|
||||
"disableMemory": get_config("grok.disable_memory", True),
|
||||
"forceSideBySide": False,
|
||||
"isAsyncChat": False,
|
||||
"disableSelfHarmShortCircuit": False,
|
||||
"disableMemory": get_config("chat.disable_memory"),
|
||||
"deviceEnvInfo": {
|
||||
"darkModeEnabled": False,
|
||||
"devicePixelRatio": 2,
|
||||
@@ -260,14 +205,11 @@ class ChatRequestBuilder:
|
||||
return payload
|
||||
|
||||
|
||||
# ==================== Grok 服务 ====================
|
||||
|
||||
|
||||
class GrokChatService:
|
||||
"""Grok API 调用服务"""
|
||||
|
||||
def __init__(self, proxy: str = None):
|
||||
self.proxy = proxy or get_config("grok.base_proxy_url", "")
|
||||
self.proxy = proxy or get_config("network.base_proxy_url")
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
@@ -280,50 +222,28 @@ class GrokChatService:
|
||||
image_attachments: List[str] = None,
|
||||
raw_payload: Dict[str, Any] = None,
|
||||
):
|
||||
"""
|
||||
发送聊天请求
|
||||
|
||||
Args:
|
||||
token: 认证 Token
|
||||
message: 消息文本
|
||||
model: Grok 模型名称
|
||||
mode: 模型模式
|
||||
stream: 是否流式
|
||||
file_attachments: 文件附件 ID 列表
|
||||
image_attachments: 图片附件 URL 列表
|
||||
|
||||
Raises:
|
||||
UpstreamException: 当 Grok API 返回错误且重试耗尽时
|
||||
"""
|
||||
"""发送聊天请求"""
|
||||
if stream is None:
|
||||
stream = get_config("grok.stream", True)
|
||||
stream = get_config("chat.stream")
|
||||
|
||||
headers = ChatRequestBuilder.build_headers(token)
|
||||
if raw_payload is not None:
|
||||
payload = raw_payload
|
||||
else:
|
||||
payload = ChatRequestBuilder.build_payload(
|
||||
message,
|
||||
model,
|
||||
mode,
|
||||
file_attachments,
|
||||
image_attachments,
|
||||
payload = (
|
||||
raw_payload
|
||||
if raw_payload is not None
|
||||
else ChatRequestBuilder.build_payload(
|
||||
message, model, mode, file_attachments, image_attachments
|
||||
)
|
||||
)
|
||||
proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None
|
||||
timeout = get_config("grok.timeout", TIMEOUT)
|
||||
timeout = get_config("network.timeout")
|
||||
|
||||
# 状态码提取器
|
||||
def extract_status(e: Exception) -> int | None:
|
||||
if isinstance(e, UpstreamException) and e.details:
|
||||
return e.details.get("status")
|
||||
return None
|
||||
logger.debug(
|
||||
f"Chat request: model={model}, mode={mode}, stream={stream}, attachments={len(file_attachments or [])}"
|
||||
)
|
||||
|
||||
logger.debug(f"Payload: {payload}")
|
||||
|
||||
# 建立连接函数
|
||||
# 建立连接
|
||||
async def establish_connection():
|
||||
"""建立连接并返回 response 对象"""
|
||||
browser = get_config("grok.browser", DEFAULT_BROWSER)
|
||||
browser = get_config("security.browser")
|
||||
session = AsyncSession(impersonate=browser)
|
||||
try:
|
||||
response = await session.post(
|
||||
@@ -336,55 +256,41 @@ class GrokChatService:
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
content = ""
|
||||
try:
|
||||
content = await response.text()
|
||||
except Exception:
|
||||
content = ""
|
||||
pass
|
||||
|
||||
logger.error(
|
||||
f"Chat failed: {response.status_code}",
|
||||
extra={
|
||||
"status": response.status_code,
|
||||
"token": token[:10] + "...",
|
||||
},
|
||||
)
|
||||
body_for_log = content if content else "<empty>"
|
||||
logger.debug(
|
||||
"Grok API error response "
|
||||
f"(status={response.status_code}): {body_for_log}"
|
||||
)
|
||||
# 关闭 session 并抛出异常
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise UpstreamException(
|
||||
message=f"Grok API request failed: {response.status_code}",
|
||||
details={
|
||||
"status": response.status_code,
|
||||
"body": content,
|
||||
},
|
||||
f"Chat failed: status={response.status_code}, token={token[:10]}..."
|
||||
)
|
||||
|
||||
# 返回 session 和 response
|
||||
await session.close()
|
||||
raise UpstreamException(
|
||||
message=f"Grok API request failed: {response.status_code}",
|
||||
details={"status": response.status_code, "body": content},
|
||||
)
|
||||
|
||||
logger.info(f"Chat connected: model={model}, stream={stream}")
|
||||
return session, response
|
||||
|
||||
except UpstreamException:
|
||||
# 已经处理过的异常,直接抛出
|
||||
raise
|
||||
except Exception as e:
|
||||
# 其他异常,关闭 session 并包装
|
||||
logger.error(f"Chat request error: {e}")
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
await session.close()
|
||||
raise UpstreamException(
|
||||
message=f"Chat connection failed: {str(e)}",
|
||||
details={"error": str(e)},
|
||||
)
|
||||
|
||||
# 建立连接
|
||||
# 重试机制
|
||||
def extract_status(e: Exception) -> int | None:
|
||||
if isinstance(e, UpstreamException) and e.details:
|
||||
return e.details.get("status")
|
||||
return None
|
||||
|
||||
session = None
|
||||
response = None
|
||||
try:
|
||||
@@ -392,7 +298,6 @@ class GrokChatService:
|
||||
establish_connection, extract_status=extract_status
|
||||
)
|
||||
except Exception as e:
|
||||
# 记录失败
|
||||
status_code = extract_status(e)
|
||||
if status_code:
|
||||
token_mgr = await get_token_manager()
|
||||
@@ -430,32 +335,28 @@ class GrokChatService:
|
||||
message, attachments = MessageExtractor.extract(
|
||||
request.messages, is_video=is_video
|
||||
)
|
||||
logger.debug(
|
||||
f"Extracted message length={len(message)}, attachments={len(attachments)}"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValidationException(str(e))
|
||||
|
||||
# 处理附件上传
|
||||
# 上传附件
|
||||
file_ids = []
|
||||
|
||||
if attachments:
|
||||
upload_service = UploadService()
|
||||
try:
|
||||
for attach_type, attach_data in attachments:
|
||||
# 获取 ID
|
||||
file_id, _ = await upload_service.upload(attach_data, token)
|
||||
|
||||
if attach_type == "image":
|
||||
file_ids.append(file_id)
|
||||
logger.debug(f"Image uploaded: {file_id}")
|
||||
else:
|
||||
file_ids.append(file_id)
|
||||
logger.debug(f"File uploaded: {file_id}")
|
||||
file_ids.append(file_id)
|
||||
logger.debug(
|
||||
f"Attachment uploaded: type={attach_type}, file_id={file_id}"
|
||||
)
|
||||
finally:
|
||||
await upload_service.close()
|
||||
|
||||
stream = (
|
||||
request.stream
|
||||
if request.stream is not None
|
||||
else get_config("grok.stream", True)
|
||||
request.stream if request.stream is not None else get_config("chat.stream")
|
||||
)
|
||||
|
||||
response = await self.chat(
|
||||
@@ -471,45 +372,9 @@ class GrokChatService:
|
||||
return response, stream, request.model
|
||||
|
||||
|
||||
# ==================== Chat 业务服务 ====================
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""Chat 业务服务"""
|
||||
|
||||
@staticmethod
|
||||
async def _wrap_stream(stream: AsyncGenerator, token_mgr, token: str, model: str):
|
||||
"""
|
||||
包装流式响应,在完成时记录使用
|
||||
|
||||
Args:
|
||||
stream: 原始 AsyncGenerator
|
||||
token_mgr: TokenManager 实例
|
||||
token: Token 字符串
|
||||
model: 模型名称
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
success = True
|
||||
finally:
|
||||
# 只在成功完成时记录使用,失败/异常时不扣费
|
||||
if success:
|
||||
try:
|
||||
model_info = ModelService.get(model)
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
logger.debug(
|
||||
f"Stream completed, recorded usage for token {token[:10]}... (effort={effort.value})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record stream usage: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def completions(
|
||||
model: str,
|
||||
@@ -517,34 +382,16 @@ class ChatService:
|
||||
stream: bool = None,
|
||||
thinking: str = None,
|
||||
):
|
||||
"""
|
||||
Chat Completions 入口
|
||||
|
||||
Args:
|
||||
model: 模型名称
|
||||
messages: 消息列表
|
||||
stream: 是否流式
|
||||
thinking: 思考模式
|
||||
|
||||
Returns:
|
||||
AsyncGenerator 或 dict
|
||||
"""
|
||||
"""Chat Completions 入口"""
|
||||
# 获取 token
|
||||
try:
|
||||
token_mgr = await get_token_manager()
|
||||
await token_mgr.reload_if_stale()
|
||||
token = None
|
||||
for pool_name in ModelService.pool_candidates_for_model(model):
|
||||
token = token_mgr.get_token(pool_name)
|
||||
if token:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get token: {e}")
|
||||
raise AppException(
|
||||
message="Internal service error obtaining token",
|
||||
error_type=ErrorType.SERVER.value,
|
||||
code="internal_error",
|
||||
)
|
||||
token_mgr = await get_token_manager()
|
||||
await token_mgr.reload_if_stale()
|
||||
|
||||
token = None
|
||||
for pool_name in ModelService.pool_candidates_for_model(model):
|
||||
token = token_mgr.get_token(pool_name)
|
||||
if token:
|
||||
break
|
||||
|
||||
if not token:
|
||||
raise AppException(
|
||||
@@ -555,13 +402,8 @@ class ChatService:
|
||||
)
|
||||
|
||||
# 解析参数
|
||||
think = None
|
||||
if thinking == "enabled":
|
||||
think = True
|
||||
elif thinking == "disabled":
|
||||
think = False
|
||||
|
||||
is_stream = stream if stream is not None else get_config("grok.stream", True)
|
||||
think = {"enabled": True, "disabled": False}.get(thinking)
|
||||
is_stream = stream if stream is not None else get_config("chat.stream")
|
||||
|
||||
# 构造请求
|
||||
chat_request = ChatRequest(
|
||||
@@ -570,40 +412,31 @@ class ChatService:
|
||||
|
||||
# 请求 Grok
|
||||
service = GrokChatService()
|
||||
try:
|
||||
response, _, model_name = await service.chat_openai(token, chat_request)
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Chat service error: {e}")
|
||||
raise UpstreamException(
|
||||
message=f"Service processing failed: {str(e)}",
|
||||
details={"error": str(e)},
|
||||
)
|
||||
response, _, model_name = await service.chat_openai(token, chat_request)
|
||||
|
||||
# 处理响应
|
||||
if is_stream:
|
||||
logger.debug(f"Processing stream response: model={model}")
|
||||
processor = StreamProcessor(model_name, token, think)
|
||||
return ChatService._wrap_stream(
|
||||
return wrap_stream_with_usage(
|
||||
processor.process(response), token_mgr, token, model
|
||||
)
|
||||
else:
|
||||
result = await CollectProcessor(model_name, token).process(response)
|
||||
# 非流式:处理完成后立即记录使用
|
||||
try:
|
||||
model_info = ModelService.get(model)
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
logger.debug(
|
||||
f"Collect completed, recorded usage for token {token[:10]}... (effort={effort.value})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record collect usage: {e}")
|
||||
return result
|
||||
|
||||
# 非流式
|
||||
logger.debug(f"Processing non-stream response: model={model}")
|
||||
result = await CollectProcessor(model_name, token).process(response)
|
||||
try:
|
||||
model_info = ModelService.get(model)
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
logger.info(f"Chat completed: model={model}, effort={effort.value}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record usage: {e}")
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -18,20 +18,8 @@ from app.core.config import get_config
|
||||
from app.core.logger import logger
|
||||
from app.services.grok.utils.headers import build_sso_cookie
|
||||
|
||||
|
||||
TIMEOUT = 120
|
||||
BLOCKED_SECONDS = 15
|
||||
FINAL_MIN_BYTES = 100000
|
||||
MEDIUM_MIN_BYTES = 30000
|
||||
|
||||
WS_URL = "wss://grok.com/ws/imagine/listen"
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
class _BlockedError(Exception):
|
||||
pass
|
||||
@@ -44,34 +32,26 @@ class ImageService:
|
||||
self._ssl_context = ssl.create_default_context()
|
||||
self._url_pattern = re.compile(r"/images/([a-f0-9-]+)\.(png|jpg|jpeg)")
|
||||
|
||||
def _get_ws_url(self) -> str:
|
||||
return WS_URL
|
||||
|
||||
def _get_timeout(self) -> float:
|
||||
return float(get_config("grok.timeout", TIMEOUT))
|
||||
|
||||
def _get_blocked_seconds(self) -> float:
|
||||
return float(get_config("grok.image_ws_blocked_seconds", BLOCKED_SECONDS))
|
||||
|
||||
def _resolve_proxy(self) -> tuple[aiohttp.BaseConnector, Optional[str]]:
|
||||
proxy_url = get_config("grok.base_proxy_url", "")
|
||||
proxy_url = get_config("network.base_proxy_url")
|
||||
if not proxy_url:
|
||||
return aiohttp.TCPConnector(ssl=self._ssl_context), None
|
||||
|
||||
scheme = urlparse(proxy_url).scheme.lower()
|
||||
if scheme.startswith("socks"):
|
||||
logger.info(f"Grok Imagine WebSocket using SOCKS proxy: {proxy_url}")
|
||||
logger.info(f"Using SOCKS proxy: {proxy_url}")
|
||||
return ProxyConnector.from_url(proxy_url, ssl=self._ssl_context), None
|
||||
|
||||
logger.info(f"Grok Imagine WebSocket using HTTP proxy: {proxy_url}")
|
||||
logger.info(f"Using HTTP proxy: {proxy_url}")
|
||||
return aiohttp.TCPConnector(ssl=self._ssl_context), proxy_url
|
||||
|
||||
def _get_ws_headers(self, token: str) -> Dict[str, str]:
|
||||
cookie = build_sso_cookie(token, include_rw=True)
|
||||
user_agent = get_config("security.user_agent")
|
||||
return {
|
||||
"Cookie": cookie,
|
||||
"Origin": "https://grok.com",
|
||||
"User-Agent": USER_AGENT,
|
||||
"User-Agent": user_agent,
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
@@ -79,13 +59,12 @@ class ImageService:
|
||||
|
||||
def _extract_image_id(self, url: str) -> Optional[str]:
|
||||
match = self._url_pattern.search(url or "")
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
return match.group(1) if match else None
|
||||
|
||||
def _is_final_image(self, url: str, blob_size: int) -> bool:
|
||||
min_bytes = int(get_config("grok.image_ws_final_min_bytes", FINAL_MIN_BYTES))
|
||||
return (url or "").lower().endswith((".jpg", ".jpeg")) and blob_size > min_bytes
|
||||
return (url or "").lower().endswith(
|
||||
(".jpg", ".jpeg")
|
||||
) and blob_size > get_config("image.image_ws_final_min_bytes")
|
||||
|
||||
def _classify_image(self, url: str, blob: str) -> Optional[Dict[str, object]]:
|
||||
if not url or not blob:
|
||||
@@ -94,12 +73,16 @@ class ImageService:
|
||||
image_id = self._extract_image_id(url) or uuid.uuid4().hex
|
||||
blob_size = len(blob)
|
||||
is_final = self._is_final_image(url, blob_size)
|
||||
if is_final:
|
||||
stage = "final"
|
||||
elif blob_size > MEDIUM_MIN_BYTES:
|
||||
stage = "medium"
|
||||
else:
|
||||
stage = "preview"
|
||||
|
||||
stage = (
|
||||
"final"
|
||||
if is_final
|
||||
else (
|
||||
"medium"
|
||||
if blob_size > get_config("image.image_ws_medium_min_bytes")
|
||||
else "preview"
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "image",
|
||||
@@ -120,11 +103,10 @@ class ImageService:
|
||||
enable_nsfw: bool = True,
|
||||
max_retries: int = None,
|
||||
) -> AsyncGenerator[Dict[str, object], None]:
|
||||
if max_retries is None:
|
||||
retries = 1
|
||||
else:
|
||||
retries = max_retries
|
||||
retries = max(1, retries)
|
||||
retries = max(1, max_retries if max_retries is not None else 1)
|
||||
logger.info(
|
||||
f"Image generation: prompt='{prompt[:50]}...', n={n}, ratio={aspect_ratio}, nsfw={enable_nsfw}"
|
||||
)
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
@@ -136,21 +118,17 @@ class ImageService:
|
||||
yield item
|
||||
return
|
||||
except _BlockedError:
|
||||
if yielded_any:
|
||||
if yielded_any or attempt + 1 >= retries:
|
||||
if not yielded_any:
|
||||
yield {
|
||||
"type": "error",
|
||||
"error_code": "blocked",
|
||||
"error": "blocked_no_final_image",
|
||||
}
|
||||
return
|
||||
if attempt + 1 < retries:
|
||||
logger.warning(
|
||||
f"Grok Imagine WebSocket blocked, retry {attempt + 1}/{retries}"
|
||||
)
|
||||
continue
|
||||
yield {
|
||||
"type": "error",
|
||||
"error_code": "blocked",
|
||||
"error": "blocked_no_final_image",
|
||||
}
|
||||
return
|
||||
logger.warning(f"WebSocket blocked, retry {attempt + 1}/{retries}")
|
||||
except Exception as e:
|
||||
logger.error(f"Grok Imagine WebSocket stream failed: {e}")
|
||||
logger.error(f"WebSocket stream failed: {e}")
|
||||
return
|
||||
|
||||
async def _stream_once(
|
||||
@@ -161,23 +139,21 @@ class ImageService:
|
||||
n: int,
|
||||
enable_nsfw: bool,
|
||||
) -> AsyncGenerator[Dict[str, object], None]:
|
||||
ws_url = self._get_ws_url()
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
headers = self._get_ws_headers(token)
|
||||
timeout = self._get_timeout()
|
||||
blocked_seconds = self._get_blocked_seconds()
|
||||
timeout = float(get_config("network.timeout"))
|
||||
blocked_seconds = float(get_config("image.image_ws_blocked_seconds"))
|
||||
|
||||
try:
|
||||
connector, proxy = self._resolve_proxy()
|
||||
except Exception as e:
|
||||
logger.error(f"Grok Imagine WebSocket proxy setup failed: {e}")
|
||||
logger.error(f"WebSocket proxy setup failed: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
async with session.ws_connect(
|
||||
ws_url,
|
||||
WS_URL,
|
||||
headers=headers,
|
||||
heartbeat=20,
|
||||
receive_timeout=timeout,
|
||||
@@ -207,26 +183,27 @@ class ImageService:
|
||||
}
|
||||
|
||||
await ws.send_json(message)
|
||||
logger.info(f"Grok Imagine WebSocket request sent: {prompt[:80]}...")
|
||||
logger.info(f"WebSocket request sent: {prompt[:80]}...")
|
||||
|
||||
images: Dict[str, Dict[str, object]] = {}
|
||||
images = {}
|
||||
completed = 0
|
||||
start_time = time.time()
|
||||
last_activity = time.time()
|
||||
start_time = last_activity = time.time()
|
||||
medium_received_time = None
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
ws_msg = await asyncio.wait_for(ws.receive(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
if medium_received_time and completed == 0:
|
||||
if time.time() - medium_received_time > min(
|
||||
10, blocked_seconds
|
||||
):
|
||||
raise _BlockedError()
|
||||
if (
|
||||
medium_received_time
|
||||
and completed == 0
|
||||
and time.time() - medium_received_time
|
||||
> min(10, blocked_seconds)
|
||||
):
|
||||
raise _BlockedError()
|
||||
if completed > 0 and time.time() - last_activity > 10:
|
||||
logger.info(
|
||||
f"Grok Imagine WebSocket idle timeout, collected {completed} images"
|
||||
f"WebSocket idle timeout, collected {completed} images"
|
||||
)
|
||||
break
|
||||
continue
|
||||
@@ -245,48 +222,54 @@ class ImageService:
|
||||
|
||||
image_id = info["image_id"]
|
||||
existing = images.get(image_id, {})
|
||||
if info["stage"] == "medium" and medium_received_time is None:
|
||||
|
||||
if (
|
||||
info["stage"] == "medium"
|
||||
and medium_received_time is None
|
||||
):
|
||||
medium_received_time = time.time()
|
||||
|
||||
if info["is_final"] and not existing.get("is_final"):
|
||||
completed += 1
|
||||
logger.debug(
|
||||
f"Final image received: id={image_id}, size={info['blob_size']}"
|
||||
)
|
||||
|
||||
images[image_id] = {
|
||||
"is_final": info["is_final"] or existing.get("is_final")
|
||||
"is_final": info["is_final"]
|
||||
or existing.get("is_final")
|
||||
}
|
||||
|
||||
yield info
|
||||
|
||||
elif msg_type == "error":
|
||||
error_code = msg.get("err_code", "")
|
||||
error_msg = msg.get("err_msg", "")
|
||||
logger.warning(
|
||||
f"Grok Imagine WebSocket error: {error_code} - {error_msg}"
|
||||
f"WebSocket error: {msg.get('err_code', '')} - {msg.get('err_msg', '')}"
|
||||
)
|
||||
yield {
|
||||
"type": "error",
|
||||
"error_code": error_code,
|
||||
"error": error_msg,
|
||||
"error_code": msg.get("err_code", ""),
|
||||
"error": msg.get("err_msg", ""),
|
||||
}
|
||||
return
|
||||
|
||||
if completed >= n:
|
||||
logger.info(
|
||||
f"Grok Imagine WebSocket collected {completed} final images"
|
||||
f"WebSocket collected {completed} final images"
|
||||
)
|
||||
break
|
||||
|
||||
if medium_received_time and completed == 0:
|
||||
if time.time() - medium_received_time > blocked_seconds:
|
||||
raise _BlockedError()
|
||||
if (
|
||||
medium_received_time
|
||||
and completed == 0
|
||||
and time.time() - medium_received_time > blocked_seconds
|
||||
):
|
||||
raise _BlockedError()
|
||||
|
||||
elif ws_msg.type in (
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
logger.warning(
|
||||
f"Grok Imagine WebSocket closed/error: {ws_msg.type}"
|
||||
)
|
||||
logger.warning(f"WebSocket closed/error: {ws_msg.type}")
|
||||
yield {
|
||||
"type": "error",
|
||||
"error_code": "ws_closed",
|
||||
@@ -295,15 +278,10 @@ class ImageService:
|
||||
break
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Grok Imagine WebSocket connection error: {e}")
|
||||
yield {
|
||||
"type": "error",
|
||||
"error_code": "connection_failed",
|
||||
"error": str(e),
|
||||
}
|
||||
logger.error(f"WebSocket connection error: {e}")
|
||||
yield {"type": "error", "error_code": "connection_failed", "error": str(e)}
|
||||
|
||||
|
||||
image_service = ImageService()
|
||||
|
||||
|
||||
__all__ = ["image_service", "ImageService"]
|
||||
+140
-307
@@ -18,30 +18,21 @@ from app.core.exceptions import (
|
||||
)
|
||||
from app.services.grok.models.model import ModelService
|
||||
from app.services.token import get_token_manager, EffortType
|
||||
from app.services.grok.processors.processor import VideoStreamProcessor, VideoCollectProcessor
|
||||
from app.services.grok.processors import VideoStreamProcessor, VideoCollectProcessor
|
||||
from app.services.grok.utils.headers import apply_statsig, build_sso_cookie
|
||||
from app.services.grok.utils.stream import wrap_stream_with_usage
|
||||
|
||||
# API 端点
|
||||
CREATE_POST_API = "https://grok.com/rest/media/post/create"
|
||||
CHAT_API = "https://grok.com/rest/app-chat/conversations/new"
|
||||
|
||||
# 常量
|
||||
DEFAULT_BROWSER = "chrome136"
|
||||
DEFAULT_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"
|
||||
TIMEOUT = 300
|
||||
DEFAULT_MAX_CONCURRENT = 50
|
||||
_MEDIA_SEMAPHORE = asyncio.Semaphore(DEFAULT_MAX_CONCURRENT)
|
||||
_MEDIA_SEM_VALUE = DEFAULT_MAX_CONCURRENT
|
||||
_MEDIA_SEMAPHORE = None
|
||||
_MEDIA_SEM_VALUE = 0
|
||||
|
||||
|
||||
def _get_media_semaphore() -> asyncio.Semaphore:
|
||||
def _get_semaphore() -> asyncio.Semaphore:
|
||||
"""获取或更新信号量"""
|
||||
global _MEDIA_SEMAPHORE, _MEDIA_SEM_VALUE
|
||||
value = get_config("performance.media_max_concurrent", DEFAULT_MAX_CONCURRENT)
|
||||
try:
|
||||
value = int(value)
|
||||
except Exception:
|
||||
value = DEFAULT_MAX_CONCURRENT
|
||||
value = max(1, value)
|
||||
value = max(1, int(get_config("performance.media_max_concurrent")))
|
||||
if value != _MEDIA_SEM_VALUE:
|
||||
_MEDIA_SEM_VALUE = value
|
||||
_MEDIA_SEMAPHORE = asyncio.Semaphore(value)
|
||||
@@ -52,14 +43,14 @@ class VideoService:
|
||||
"""视频生成服务"""
|
||||
|
||||
def __init__(self, proxy: str = None):
|
||||
self.proxy = proxy or get_config("grok.base_proxy_url", "")
|
||||
self.timeout = get_config("grok.timeout", TIMEOUT)
|
||||
self.proxy = proxy or get_config("network.base_proxy_url")
|
||||
self.timeout = get_config("network.timeout")
|
||||
|
||||
def _build_headers(
|
||||
self, token: str, referer: str = "https://grok.com/imagine"
|
||||
) -> dict:
|
||||
"""构建请求头"""
|
||||
user_agent = get_config("grok.user_agent", DEFAULT_USER_AGENT)
|
||||
user_agent = get_config("security.user_agent")
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
@@ -99,18 +90,7 @@ class VideoService:
|
||||
media_type: str = "MEDIA_POST_TYPE_VIDEO",
|
||||
media_url: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
创建媒体帖子
|
||||
|
||||
Args:
|
||||
token: 认证 Token
|
||||
prompt: 提示词(视频生成用)
|
||||
media_type: 媒体类型 (MEDIA_POST_TYPE_VIDEO 或 MEDIA_POST_TYPE_IMAGE)
|
||||
media_url: 媒体 URL(图片模式用)
|
||||
|
||||
Returns:
|
||||
post ID
|
||||
"""
|
||||
"""创建媒体帖子,返回 post ID"""
|
||||
try:
|
||||
headers = self._build_headers(token)
|
||||
|
||||
@@ -121,12 +101,11 @@ class VideoService:
|
||||
payload = {"mediaType": media_type, "prompt": prompt}
|
||||
|
||||
async with AsyncSession() as session:
|
||||
browser = get_config("grok.browser", DEFAULT_BROWSER)
|
||||
response = await session.post(
|
||||
CREATE_POST_API,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
impersonate=browser,
|
||||
impersonate=get_config("security.browser"),
|
||||
timeout=30,
|
||||
proxies=self._build_proxies(),
|
||||
)
|
||||
@@ -137,32 +116,21 @@ class VideoService:
|
||||
f"Failed to create post: {response.status_code}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
post_id = data.get("post", {}).get("id", "")
|
||||
|
||||
post_id = response.json().get("post", {}).get("id", "")
|
||||
if not post_id:
|
||||
raise UpstreamException("No post ID in response")
|
||||
|
||||
logger.info(f"Media post created: {post_id} (type={media_type})")
|
||||
return post_id
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Create post error: {e}")
|
||||
if isinstance(e, AppException):
|
||||
raise e
|
||||
raise UpstreamException(f"Create post error: {str(e)}")
|
||||
|
||||
async def create_image_post(self, token: str, image_url: str) -> str:
|
||||
"""
|
||||
创建图片帖子
|
||||
|
||||
Args:
|
||||
token: 认证 Token
|
||||
image_url: 完整的图片 URL (https://assets.grok.com/...)
|
||||
|
||||
Returns:
|
||||
post ID
|
||||
"""
|
||||
"""创建图片帖子,返回 post ID"""
|
||||
return await self.create_post(
|
||||
token, prompt="", media_type="MEDIA_POST_TYPE_IMAGE", media_url=image_url
|
||||
)
|
||||
@@ -177,37 +145,93 @@ class VideoService:
|
||||
preset: str = "normal",
|
||||
) -> dict:
|
||||
"""构建视频生成载荷"""
|
||||
mode_flag = "--mode=custom"
|
||||
if preset == "fun":
|
||||
mode_flag = "--mode=extremely-crazy"
|
||||
elif preset == "normal":
|
||||
mode_flag = "--mode=normal"
|
||||
elif preset == "spicy":
|
||||
mode_flag = "--mode=extremely-spicy-or-crazy"
|
||||
|
||||
full_prompt = f"{prompt} {mode_flag}"
|
||||
video_gen_config = {
|
||||
"parentPostId": post_id,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"videoLength": video_length,
|
||||
"resolutionName": resolution_name,
|
||||
mode_map = {
|
||||
"fun": "--mode=extremely-crazy",
|
||||
"normal": "--mode=normal",
|
||||
"spicy": "--mode=extremely-spicy-or-crazy",
|
||||
}
|
||||
mode_flag = mode_map.get(preset, "--mode=custom")
|
||||
|
||||
return {
|
||||
"temporary": True,
|
||||
"modelName": "grok-3",
|
||||
"message": full_prompt,
|
||||
"message": f"{prompt} {mode_flag}",
|
||||
"toolOverrides": {"videoGen": True},
|
||||
"enableSideBySide": True,
|
||||
"responseMetadata": {
|
||||
"experiments": [],
|
||||
"modelConfigOverride": {
|
||||
"modelMap": {
|
||||
"videoGenModelConfig": video_gen_config
|
||||
"videoGenModelConfig": {
|
||||
"parentPostId": post_id,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"videoLength": video_length,
|
||||
"resolutionName": resolution_name,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async def _generate_internal(
|
||||
self,
|
||||
token: str,
|
||||
post_id: str,
|
||||
prompt: str,
|
||||
aspect_ratio: str,
|
||||
video_length: int,
|
||||
resolution_name: str,
|
||||
preset: str,
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""内部生成逻辑"""
|
||||
session = None
|
||||
try:
|
||||
headers = self._build_headers(token)
|
||||
payload = self._build_payload(
|
||||
prompt, post_id, aspect_ratio, video_length, resolution_name, preset
|
||||
)
|
||||
|
||||
session = AsyncSession(impersonate=get_config("security.browser"))
|
||||
response = await session.post(
|
||||
CHAT_API,
|
||||
headers=headers,
|
||||
data=orjson.dumps(payload),
|
||||
timeout=self.timeout,
|
||||
stream=True,
|
||||
proxies=self._build_proxies(),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Video generation failed: status={response.status_code}, post_id={post_id}"
|
||||
)
|
||||
raise UpstreamException(
|
||||
message=f"Video generation failed: {response.status_code}",
|
||||
details={"status": response.status_code},
|
||||
)
|
||||
|
||||
logger.info(f"Video generation started: post_id={post_id}")
|
||||
|
||||
async def stream_response():
|
||||
try:
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
return stream_response()
|
||||
|
||||
except Exception as e:
|
||||
if session:
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"Video generation error: {e}")
|
||||
if isinstance(e, AppException):
|
||||
raise
|
||||
raise UpstreamException(f"Video generation error: {str(e)}")
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
token: str,
|
||||
@@ -217,78 +241,21 @@ class VideoService:
|
||||
resolution_name: str = "480p",
|
||||
preset: str = "normal",
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
生成视频
|
||||
|
||||
Args:
|
||||
token: 认证 Token
|
||||
prompt: 视频描述
|
||||
aspect_ratio: 宽高比
|
||||
video_length: 视频时长
|
||||
resolution_name: 分辨率
|
||||
preset: 预设
|
||||
|
||||
Returns:
|
||||
AsyncGenerator,流式传输
|
||||
|
||||
Raises:
|
||||
UpstreamException: 连接失败时
|
||||
"""
|
||||
async with _get_media_semaphore():
|
||||
session = None
|
||||
try:
|
||||
# Step 1: 创建帖子
|
||||
post_id = await self.create_post(token, prompt)
|
||||
|
||||
# Step 2: 建立连接
|
||||
headers = self._build_headers(token)
|
||||
payload = self._build_payload(
|
||||
prompt, post_id, aspect_ratio, video_length, resolution_name, preset
|
||||
)
|
||||
|
||||
browser = get_config("grok.browser", DEFAULT_BROWSER)
|
||||
session = AsyncSession(impersonate=browser)
|
||||
response = await session.post(
|
||||
CHAT_API,
|
||||
headers=headers,
|
||||
data=orjson.dumps(payload),
|
||||
timeout=self.timeout,
|
||||
stream=True,
|
||||
proxies=self._build_proxies(),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Video generation failed: {response.status_code}")
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise UpstreamException(
|
||||
message=f"Video generation failed: {response.status_code}",
|
||||
details={"status": response.status_code},
|
||||
)
|
||||
|
||||
# Step 3: 流式传输
|
||||
async def stream_response():
|
||||
try:
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
finally:
|
||||
if session:
|
||||
await session.close()
|
||||
|
||||
return stream_response()
|
||||
|
||||
except Exception as e:
|
||||
if session:
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"Video generation error: {e}")
|
||||
if isinstance(e, AppException):
|
||||
raise e
|
||||
raise UpstreamException(f"Video generation error: {str(e)}")
|
||||
"""生成视频"""
|
||||
logger.info(
|
||||
f"Video generation: prompt='{prompt[:50]}...', ratio={aspect_ratio}, length={video_length}s, preset={preset}"
|
||||
)
|
||||
async with _get_semaphore():
|
||||
post_id = await self.create_post(token, prompt)
|
||||
return await self._generate_internal(
|
||||
token,
|
||||
post_id,
|
||||
prompt,
|
||||
aspect_ratio,
|
||||
video_length,
|
||||
resolution_name,
|
||||
preset,
|
||||
)
|
||||
|
||||
async def generate_from_image(
|
||||
self,
|
||||
@@ -300,109 +267,15 @@ class VideoService:
|
||||
resolution: str = "480p",
|
||||
preset: str = "normal",
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
从图片生成视频
|
||||
|
||||
Args:
|
||||
token: 认证 Token
|
||||
prompt: 视频描述
|
||||
image_url: 图片 URL
|
||||
aspect_ratio: 宽高比
|
||||
video_length: 视频时长
|
||||
resolution: 分辨率
|
||||
preset: 预设
|
||||
|
||||
Returns:
|
||||
AsyncGenerator,流式传输
|
||||
"""
|
||||
async with _get_media_semaphore():
|
||||
session = None
|
||||
try:
|
||||
# Step 1: 创建帖子
|
||||
post_id = await self.create_image_post(token, image_url)
|
||||
|
||||
# Step 2: 建立连接
|
||||
headers = self._build_headers(token)
|
||||
payload = self._build_payload(
|
||||
prompt, post_id, aspect_ratio, video_length, resolution, preset
|
||||
)
|
||||
|
||||
browser = get_config("grok.browser", DEFAULT_BROWSER)
|
||||
session = AsyncSession(impersonate=browser)
|
||||
response = await session.post(
|
||||
CHAT_API,
|
||||
headers=headers,
|
||||
data=orjson.dumps(payload),
|
||||
timeout=self.timeout,
|
||||
stream=True,
|
||||
proxies=self._build_proxies(),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Video from image failed: {response.status_code}")
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise UpstreamException(
|
||||
message=f"Video from image failed: {response.status_code}",
|
||||
details={"status": response.status_code},
|
||||
)
|
||||
|
||||
# Step 3: 流式传输
|
||||
async def stream_response():
|
||||
try:
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
finally:
|
||||
if session:
|
||||
await session.close()
|
||||
|
||||
return stream_response()
|
||||
|
||||
except Exception as e:
|
||||
if session:
|
||||
try:
|
||||
await session.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"Video from image error: {e}")
|
||||
if isinstance(e, AppException):
|
||||
raise e
|
||||
raise UpstreamException(f"Video from image error: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
async def _wrap_stream(stream: AsyncGenerator, token_mgr, token: str, model: str):
|
||||
"""
|
||||
包装流式响应,在完成时记录使用
|
||||
|
||||
Args:
|
||||
stream: 原始 AsyncGenerator
|
||||
token_mgr: TokenManager 实例
|
||||
token: Token 字符串
|
||||
model: 模型名称
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
success = True
|
||||
finally:
|
||||
# 只在成功完成时记录使用,失败/异常时不扣费
|
||||
if success:
|
||||
try:
|
||||
model_info = ModelService.get(model)
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
logger.debug(
|
||||
f"Video stream completed, recorded usage for token {token[:10]}... (effort={effort.value})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record video stream usage: {e}")
|
||||
"""从图片生成视频"""
|
||||
logger.info(
|
||||
f"Image to video: prompt='{prompt[:50]}...', image={image_url[:80]}"
|
||||
)
|
||||
async with _get_semaphore():
|
||||
post_id = await self.create_image_post(token, image_url)
|
||||
return await self._generate_internal(
|
||||
token, post_id, prompt, aspect_ratio, video_length, resolution, preset
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def completions(
|
||||
@@ -415,38 +288,16 @@ class VideoService:
|
||||
resolution: str = "480p",
|
||||
preset: str = "normal",
|
||||
):
|
||||
"""
|
||||
视频生成入口
|
||||
|
||||
Args:
|
||||
model: 模型名称
|
||||
messages: 消息列表
|
||||
stream: 是否流式
|
||||
thinking: 思考模式
|
||||
aspect_ratio: 宽高比
|
||||
video_length: 视频时长
|
||||
resolution: 分辨率
|
||||
preset: 预设模式
|
||||
|
||||
Returns:
|
||||
AsyncGenerator (流式) 或 dict (非流式)
|
||||
"""
|
||||
"""视频生成入口"""
|
||||
# 获取 token
|
||||
try:
|
||||
token_mgr = await get_token_manager()
|
||||
await token_mgr.reload_if_stale()
|
||||
token = None
|
||||
for pool_name in ModelService.pool_candidates_for_model(model):
|
||||
token = token_mgr.get_token(pool_name)
|
||||
if token:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get token: {e}")
|
||||
raise AppException(
|
||||
message="Internal service error obtaining token",
|
||||
error_type=ErrorType.SERVER.value,
|
||||
code="internal_error",
|
||||
)
|
||||
token_mgr = await get_token_manager()
|
||||
await token_mgr.reload_if_stale()
|
||||
|
||||
token = None
|
||||
for pool_name in ModelService.pool_candidates_for_model(model):
|
||||
token = token_mgr.get_token(pool_name)
|
||||
if token:
|
||||
break
|
||||
|
||||
if not token:
|
||||
raise AppException(
|
||||
@@ -456,14 +307,8 @@ class VideoService:
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
# 解析参数
|
||||
think = None
|
||||
if thinking == "enabled":
|
||||
think = True
|
||||
elif thinking == "disabled":
|
||||
think = False
|
||||
|
||||
is_stream = stream if stream is not None else get_config("grok.stream", True)
|
||||
think = {"enabled": True, "disabled": False}.get(thinking)
|
||||
is_stream = stream if stream is not None else get_config("chat.stream")
|
||||
|
||||
# 提取内容
|
||||
from app.services.grok.services.chat import MessageExtractor
|
||||
@@ -481,27 +326,18 @@ class VideoService:
|
||||
try:
|
||||
for attach_type, attach_data in attachments:
|
||||
if attach_type == "image":
|
||||
# 上传图片
|
||||
_, file_uri = await upload_service.upload(attach_data, token)
|
||||
image_url = f"https://assets.grok.com/{file_uri}"
|
||||
logger.info(f"Image uploaded for video: {image_url}")
|
||||
break # 视频模型只使用第一张图片
|
||||
break
|
||||
finally:
|
||||
await upload_service.close()
|
||||
|
||||
# 生成视频
|
||||
service = VideoService()
|
||||
|
||||
# 图片转视频
|
||||
if image_url:
|
||||
response = await service.generate_from_image(
|
||||
token,
|
||||
prompt,
|
||||
image_url,
|
||||
aspect_ratio,
|
||||
video_length,
|
||||
resolution,
|
||||
preset,
|
||||
token, prompt, image_url, aspect_ratio, video_length, resolution, preset
|
||||
)
|
||||
else:
|
||||
response = await service.generate(
|
||||
@@ -511,26 +347,23 @@ class VideoService:
|
||||
# 处理响应
|
||||
if is_stream:
|
||||
processor = VideoStreamProcessor(model, token, think)
|
||||
return VideoService._wrap_stream(
|
||||
return wrap_stream_with_usage(
|
||||
processor.process(response), token_mgr, token, model
|
||||
)
|
||||
else:
|
||||
result = await VideoCollectProcessor(model, token).process(response)
|
||||
# 非流式:处理完成后立即记录使用
|
||||
try:
|
||||
model_info = ModelService.get(model)
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
logger.debug(
|
||||
f"Video completed, recorded usage for token {token[:10]}... (effort={effort.value})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record video usage: {e}")
|
||||
return result
|
||||
|
||||
result = await VideoCollectProcessor(model, token).process(response)
|
||||
try:
|
||||
model_info = ModelService.get(model)
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
logger.debug(f"Video completed, recorded usage (effort={effort.value})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record video usage: {e}")
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["VideoService"]
|
||||
@@ -20,12 +20,8 @@ from app.services.grok.protocols.grpc_web import (
|
||||
)
|
||||
from app.services.grok.utils.headers import build_sso_cookie
|
||||
|
||||
|
||||
NSFW_API = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls"
|
||||
BIRTH_DATE_API = "https://grok.com/rest/auth/set-birth-date"
|
||||
DEFAULT_BROWSER = "chrome136"
|
||||
DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -43,14 +39,18 @@ class NSFWService:
|
||||
"""NSFW 模式服务"""
|
||||
|
||||
def __init__(self, proxy: str = None):
|
||||
self.proxy = proxy or get_config("grok.base_proxy_url", "")
|
||||
self.proxy = proxy or get_config("network.base_proxy_url")
|
||||
self.timeout = float(get_config("network.timeout"))
|
||||
|
||||
def _build_proxies(self) -> Optional[dict]:
|
||||
"""构建代理配置"""
|
||||
return {"http": self.proxy, "https": self.proxy} if self.proxy else None
|
||||
|
||||
@staticmethod
|
||||
def _random_birth_date() -> str:
|
||||
"""生成随机出生日期"""
|
||||
"""生成随机出生日期(20-40岁)"""
|
||||
today = datetime.date.today()
|
||||
age = random.randint(20, 40)
|
||||
birth_year = today.year - age
|
||||
birth_year = today.year - random.randint(20, 40)
|
||||
birth_month = random.randint(1, 12)
|
||||
birth_day = random.randint(1, 28)
|
||||
hour = random.randint(0, 23)
|
||||
@@ -62,7 +62,7 @@ class NSFWService:
|
||||
def _build_headers(self, token: str) -> dict:
|
||||
"""构造 gRPC-Web 请求头"""
|
||||
cookie = build_sso_cookie(token, include_rw=True)
|
||||
user_agent = get_config("grok.user_agent", DEFAULT_USER_AGENT)
|
||||
user_agent = get_config("security.user_agent")
|
||||
return {
|
||||
"accept": "*/*",
|
||||
"content-type": "application/grpc-web+proto",
|
||||
@@ -77,7 +77,7 @@ class NSFWService:
|
||||
def _build_birth_headers(self, token: str) -> dict:
|
||||
"""构造设置出生日期请求头"""
|
||||
cookie = build_sso_cookie(token, include_rw=True)
|
||||
user_agent = get_config("grok.user_agent", DEFAULT_USER_AGENT)
|
||||
user_agent = get_config("security.user_agent")
|
||||
return {
|
||||
"accept": "*/*",
|
||||
"content-type": "application/json",
|
||||
@@ -99,18 +99,20 @@ class NSFWService:
|
||||
protobuf = b"\x0a\x02\x10\x01\x12" + bytes([len(inner)]) + inner
|
||||
return encode_grpc_web_payload(protobuf)
|
||||
|
||||
async def _set_birth_date(self, session: AsyncSession, token: str) -> tuple[bool, int, Optional[str]]:
|
||||
async def _set_birth_date(
|
||||
self, session: AsyncSession, token: str
|
||||
) -> tuple[bool, int, Optional[str]]:
|
||||
"""设置出生日期"""
|
||||
headers = self._build_birth_headers(token)
|
||||
payload = {"birthDate": self._random_birth_date()}
|
||||
proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None
|
||||
|
||||
try:
|
||||
response = await session.post(
|
||||
BIRTH_DATE_API,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=TIMEOUT,
|
||||
proxies=proxies,
|
||||
timeout=self.timeout,
|
||||
proxies=self._build_proxies(),
|
||||
)
|
||||
if response.status_code in (200, 204):
|
||||
return True, response.status_code, None
|
||||
@@ -122,31 +124,27 @@ class NSFWService:
|
||||
"""为单个 token 开启 NSFW 模式"""
|
||||
headers = self._build_headers(token)
|
||||
payload = self._build_payload()
|
||||
logger.debug(
|
||||
"NSFW payload: len={} hex={}",
|
||||
len(payload),
|
||||
payload.hex(),
|
||||
)
|
||||
proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None
|
||||
logger.debug(f"NSFW payload: len={len(payload)} hex={payload.hex()}")
|
||||
|
||||
try:
|
||||
browser = get_config("grok.browser", DEFAULT_BROWSER)
|
||||
browser = get_config("security.browser")
|
||||
async with AsyncSession(impersonate=browser) as session:
|
||||
ok, birth_status, birth_err = await self._set_birth_date(
|
||||
session, token
|
||||
)
|
||||
# 先设置出生日期
|
||||
ok, birth_status, birth_err = await self._set_birth_date(session, token)
|
||||
if not ok:
|
||||
return NSFWResult(
|
||||
success=False,
|
||||
http_status=birth_status,
|
||||
error=f"Set birth date failed: {birth_err}",
|
||||
)
|
||||
|
||||
# 开启 NSFW
|
||||
response = await session.post(
|
||||
NSFW_API,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
timeout=TIMEOUT,
|
||||
proxies=proxies,
|
||||
timeout=self.timeout,
|
||||
proxies=self._build_proxies(),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -157,18 +155,14 @@ class NSFWService:
|
||||
)
|
||||
|
||||
# 解析 gRPC-Web 响应
|
||||
content_type = response.headers.get("content-type")
|
||||
_, trailers = parse_grpc_web_response(
|
||||
response.content, content_type=content_type
|
||||
response.content, content_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
grpc_status = get_grpc_status(trailers)
|
||||
logger.debug(
|
||||
"NSFW response: http={} grpc={} msg={} trailers={}",
|
||||
response.status_code,
|
||||
grpc_status.code,
|
||||
grpc_status.message,
|
||||
trailers,
|
||||
f"NSFW response: http={response.status_code} grpc={grpc_status.code} "
|
||||
f"msg={grpc_status.message} trailers={trailers}"
|
||||
)
|
||||
|
||||
# HTTP 200 且无 grpc-status(空响应)或 grpc-status=0 都算成功
|
||||
|
||||
@@ -13,40 +13,22 @@ from app.core.exceptions import UpstreamException
|
||||
from app.services.grok.utils.headers import apply_statsig, build_sso_cookie
|
||||
from app.services.grok.utils.retry import retry_on_status
|
||||
|
||||
|
||||
LIMITS_API = "https://grok.com/rest/rate-limits"
|
||||
DEFAULT_BROWSER = "chrome136"
|
||||
DEFAULT_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"
|
||||
TIMEOUT = 10
|
||||
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)
|
||||
try:
|
||||
value = int(value)
|
||||
except Exception:
|
||||
value = DEFAULT_MAX_CONCURRENT
|
||||
value = max(1, value)
|
||||
if value != _USAGE_SEM_VALUE:
|
||||
_USAGE_SEM_VALUE = value
|
||||
_USAGE_SEMAPHORE = asyncio.Semaphore(value)
|
||||
return _USAGE_SEMAPHORE
|
||||
_USAGE_SEMAPHORE = asyncio.Semaphore(25)
|
||||
_USAGE_SEM_VALUE = 25
|
||||
|
||||
|
||||
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)
|
||||
self.proxy = proxy or get_config("network.base_proxy_url")
|
||||
self.timeout = get_config("network.timeout")
|
||||
|
||||
def _build_headers(self, token: str) -> dict:
|
||||
"""构建请求头"""
|
||||
user_agent = get_config("grok.user_agent", DEFAULT_USER_AGENT)
|
||||
user_agent = get_config("security.user_agent")
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
@@ -93,7 +75,17 @@ class UsageService:
|
||||
Raises:
|
||||
UpstreamException: 当获取失败且重试耗尽时
|
||||
"""
|
||||
async with _get_usage_semaphore():
|
||||
value = get_config("performance.usage_max_concurrent")
|
||||
try:
|
||||
value = int(value)
|
||||
except Exception:
|
||||
value = 25
|
||||
value = max(1, value)
|
||||
global _USAGE_SEMAPHORE, _USAGE_SEM_VALUE
|
||||
if value != _USAGE_SEM_VALUE:
|
||||
_USAGE_SEM_VALUE = value
|
||||
_USAGE_SEMAPHORE = asyncio.Semaphore(value)
|
||||
async with _USAGE_SEMAPHORE:
|
||||
# 定义状态码提取器
|
||||
def extract_status(e: Exception) -> int | None:
|
||||
if isinstance(e, UpstreamException) and e.details:
|
||||
@@ -105,7 +97,7 @@ class UsageService:
|
||||
try:
|
||||
headers = self._build_headers(token)
|
||||
payload = {"requestKind": "DEFAULT", "modelName": model_name}
|
||||
browser = get_config("grok.browser", DEFAULT_BROWSER)
|
||||
browser = get_config("security.browser")
|
||||
|
||||
async with AsyncSession() as session:
|
||||
response = await session.post(
|
||||
@@ -120,10 +112,14 @@ class UsageService:
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
remaining = data.get("remainingTokens", 0)
|
||||
logger.info(f"Usage: quota {remaining} remaining")
|
||||
logger.info(
|
||||
f"Usage sync success: remaining={remaining}, token={token[:10]}..."
|
||||
)
|
||||
return data
|
||||
|
||||
logger.error(f"Usage failed: {response.status_code}")
|
||||
logger.error(
|
||||
f"Usage sync failed: status={response.status_code}, token={token[:10]}..."
|
||||
)
|
||||
|
||||
raise UpstreamException(
|
||||
message=f"Failed to get usage stats: {response.status_code}",
|
||||
|
||||
@@ -12,17 +12,14 @@ from app.core.config import get_config
|
||||
from app.core.exceptions import UpstreamException
|
||||
from app.services.grok.utils.headers import apply_statsig, build_sso_cookie
|
||||
|
||||
|
||||
LIVEKIT_TOKEN_API = "https://grok.com/rest/livekit/tokens"
|
||||
TIMEOUT = 30
|
||||
DEFAULT_BROWSER = "chrome136"
|
||||
|
||||
|
||||
class VoiceService:
|
||||
"""Voice Mode Service (LiveKit)"""
|
||||
|
||||
def __init__(self, proxy: str = None):
|
||||
self.proxy = proxy or get_config("grok.base_proxy_url", "")
|
||||
self.proxy = proxy or get_config("network.base_proxy_url")
|
||||
|
||||
async def get_token(
|
||||
self,
|
||||
@@ -33,39 +30,45 @@ class VoiceService:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get LiveKit token
|
||||
|
||||
|
||||
Args:
|
||||
token: Auth token
|
||||
Returns:
|
||||
Dict containing token and livekitUrl
|
||||
"""
|
||||
logger.debug(
|
||||
f"Voice token request: voice={voice}, personality={personality}, speed={speed}"
|
||||
)
|
||||
headers = self._build_headers(token)
|
||||
payload = self._build_payload(voice, personality, speed)
|
||||
|
||||
|
||||
proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None
|
||||
|
||||
|
||||
try:
|
||||
browser = get_config("grok.browser", DEFAULT_BROWSER)
|
||||
browser = get_config("security.browser")
|
||||
timeout = get_config("network.timeout")
|
||||
async with AsyncSession(impersonate=browser) as session:
|
||||
response = await session.post(
|
||||
LIVEKIT_TOKEN_API,
|
||||
headers=headers,
|
||||
data=orjson.dumps(payload),
|
||||
timeout=TIMEOUT,
|
||||
timeout=timeout,
|
||||
proxies=proxies,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
body = response.text[:200]
|
||||
logger.error(
|
||||
f"Voice token failed: {response.status_code}",
|
||||
extra={"response": response.text[:200]}
|
||||
f"Voice token failed: status={response.status_code}, body={body}"
|
||||
)
|
||||
raise UpstreamException(
|
||||
message=f"Failed to get voice token: {response.status_code}",
|
||||
details={"status": response.status_code, "body": response.text}
|
||||
details={"status": response.status_code, "body": response.text},
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
result = response.json()
|
||||
logger.info(f"Voice token obtained: voice={voice}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Voice service error: {e}")
|
||||
@@ -95,14 +98,16 @@ class VoiceService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Construct payload with voice settings"""
|
||||
return {
|
||||
"sessionPayload": orjson.dumps({
|
||||
"voice": voice,
|
||||
"personality": personality,
|
||||
"playback_speed": speed,
|
||||
"enable_vision": False,
|
||||
"turn_detection": {"type": "server_vad"}
|
||||
}).decode(),
|
||||
"sessionPayload": orjson.dumps(
|
||||
{
|
||||
"voice": voice,
|
||||
"personality": personality,
|
||||
"playback_speed": speed,
|
||||
"enable_vision": False,
|
||||
"turn_detection": {"type": "server_vad"},
|
||||
}
|
||||
).decode(),
|
||||
"requestAgentDispatch": False,
|
||||
"livekitUrl": "wss://livekit.grok.com",
|
||||
"params": {"enable_markdown_transcript": "true"}
|
||||
"params": {"enable_markdown_transcript": "true"},
|
||||
}
|
||||
@@ -17,7 +17,7 @@ def _normalize_token(token: str) -> str:
|
||||
|
||||
def build_sso_cookie(token: str, include_rw: bool = False) -> str:
|
||||
token = _normalize_token(token)
|
||||
cf = get_config("grok.cf_clearance", "")
|
||||
cf = get_config("security.cf_clearance")
|
||||
cookie = f"sso={token}"
|
||||
if include_rw:
|
||||
cookie = f"{cookie}; sso-rw={token}"
|
||||
|
||||
@@ -10,7 +10,7 @@ Grok API 重试工具
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from typing import Callable, Any, Optional, List
|
||||
from typing import Callable, Any, Optional
|
||||
from functools import wraps
|
||||
|
||||
from app.core.logger import logger
|
||||
@@ -18,56 +18,22 @@ from app.core.config import get_config
|
||||
from app.core.exceptions import UpstreamException
|
||||
|
||||
|
||||
class RetryConfig:
|
||||
"""重试配置"""
|
||||
|
||||
@staticmethod
|
||||
def get_max_retry() -> int:
|
||||
"""获取最大重试次数"""
|
||||
return get_config("grok.max_retry", 3)
|
||||
|
||||
@staticmethod
|
||||
def get_retry_codes() -> List[int]:
|
||||
"""获取可重试的状态码"""
|
||||
return get_config("grok.retry_status_codes", [401, 429, 403])
|
||||
|
||||
@staticmethod
|
||||
def get_backoff_base() -> float:
|
||||
"""获取退避基础时间(秒)"""
|
||||
return get_config("grok.retry_backoff_base", 0.5)
|
||||
|
||||
@staticmethod
|
||||
def get_backoff_factor() -> float:
|
||||
"""获取退避因子"""
|
||||
return get_config("grok.retry_backoff_factor", 2.0)
|
||||
|
||||
@staticmethod
|
||||
def get_backoff_max() -> float:
|
||||
"""获取最大退避时间(秒)"""
|
||||
return get_config("grok.retry_backoff_max", 30.0)
|
||||
|
||||
@staticmethod
|
||||
def get_retry_budget() -> float:
|
||||
"""获取重试总预算时间(秒)"""
|
||||
return get_config("grok.retry_budget", 90.0)
|
||||
|
||||
|
||||
class RetryContext:
|
||||
"""重试上下文"""
|
||||
|
||||
def __init__(self):
|
||||
self.attempt = 0
|
||||
self.max_retry = RetryConfig.get_max_retry()
|
||||
self.retry_codes = RetryConfig.get_retry_codes()
|
||||
self.max_retry = int(get_config("retry.max_retry"))
|
||||
self.retry_codes = get_config("retry.retry_status_codes")
|
||||
self.last_error = None
|
||||
self.last_status = None
|
||||
self.total_delay = 0.0
|
||||
self.retry_budget = RetryConfig.get_retry_budget()
|
||||
self.retry_budget = float(get_config("retry.retry_budget"))
|
||||
|
||||
# 退避参数
|
||||
self.backoff_base = RetryConfig.get_backoff_base()
|
||||
self.backoff_factor = RetryConfig.get_backoff_factor()
|
||||
self.backoff_max = RetryConfig.get_backoff_max()
|
||||
self.backoff_base = float(get_config("retry.retry_backoff_base"))
|
||||
self.backoff_factor = float(get_config("retry.retry_backoff_factor"))
|
||||
self.backoff_max = float(get_config("retry.retry_backoff_max"))
|
||||
|
||||
# decorrelated jitter 状态
|
||||
self._last_delay = self.backoff_base
|
||||
@@ -291,7 +257,6 @@ def with_retry(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RetryConfig",
|
||||
"RetryContext",
|
||||
"retry_on_status",
|
||||
"with_retry",
|
||||
|
||||
@@ -30,8 +30,7 @@ class StatsigService:
|
||||
Returns:
|
||||
Base64 编码的 ID
|
||||
"""
|
||||
# 读取配置
|
||||
dynamic = get_config("grok.dynamic_statsig", True)
|
||||
dynamic = get_config("chat.dynamic_statsig")
|
||||
|
||||
if not dynamic:
|
||||
return "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
流式响应通用工具
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.core.logger import logger
|
||||
from app.services.grok.models.model import ModelService
|
||||
from app.services.token import EffortType
|
||||
|
||||
|
||||
async def wrap_stream_with_usage(
|
||||
stream: AsyncGenerator, token_mgr, token: str, model: str
|
||||
) -> AsyncGenerator:
|
||||
"""
|
||||
包装流式响应,在完成时记录使用
|
||||
|
||||
Args:
|
||||
stream: 原始 AsyncGenerator
|
||||
token_mgr: TokenManager 实例
|
||||
token: Token 字符串
|
||||
model: 模型名称
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
success = True
|
||||
finally:
|
||||
if success:
|
||||
try:
|
||||
model_info = ModelService.get(model)
|
||||
effort = (
|
||||
EffortType.HIGH
|
||||
if (model_info and model_info.cost.value == "high")
|
||||
else EffortType.LOW
|
||||
)
|
||||
await token_mgr.consume(token, effort)
|
||||
logger.debug(
|
||||
f"Stream completed, recorded usage for token {token[:10]}... (effort={effort.value})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record stream usage: {e}")
|
||||
|
||||
|
||||
__all__ = ["wrap_stream_with_usage"]
|
||||
@@ -18,9 +18,13 @@ from app.core.storage import get_storage
|
||||
from app.core.config import get_config
|
||||
from app.services.token.pool import TokenPool
|
||||
|
||||
# 批量刷新配置
|
||||
REFRESH_BATCH_SIZE = 10
|
||||
REFRESH_CONCURRENCY = 5
|
||||
|
||||
DEFAULT_REFRESH_BATCH_SIZE = 10
|
||||
DEFAULT_REFRESH_CONCURRENCY = 5
|
||||
DEFAULT_SUPER_REFRESH_INTERVAL_HOURS = 2
|
||||
DEFAULT_REFRESH_INTERVAL_HOURS = 8
|
||||
DEFAULT_RELOAD_INTERVAL_SEC = 30
|
||||
DEFAULT_SAVE_DELAY_MS = 500
|
||||
|
||||
SUPER_POOL_NAME = "ssoSuper"
|
||||
BASIC_POOL_NAME = "ssoBasic"
|
||||
@@ -32,12 +36,6 @@ def _default_quota_for_pool(pool_name: str) -> int:
|
||||
return BASIC__DEFAULT_QUOTA
|
||||
|
||||
|
||||
def _refresh_interval_hours_for_pool(pool_name: str) -> float:
|
||||
if pool_name == SUPER_POOL_NAME:
|
||||
return get_config("token.super_refresh_interval_hours", 2)
|
||||
return get_config("token.refresh_interval_hours", 8)
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""管理 Token 的增删改查和配额同步"""
|
||||
|
||||
@@ -50,7 +48,7 @@ class TokenManager:
|
||||
self._save_lock = asyncio.Lock()
|
||||
self._dirty = False
|
||||
self._save_task: Optional[asyncio.Task] = None
|
||||
self._save_delay = 0.5
|
||||
self._save_delay = DEFAULT_SAVE_DELAY_MS / 1000.0
|
||||
self._last_reload_at = 0.0
|
||||
|
||||
@classmethod
|
||||
@@ -131,11 +129,11 @@ class TokenManager:
|
||||
|
||||
async def reload_if_stale(self):
|
||||
"""在多 worker 场景下保持短周期一致性"""
|
||||
interval = get_config("token.reload_interval_sec", 30)
|
||||
interval = get_config("token.reload_interval_sec", DEFAULT_RELOAD_INTERVAL_SEC)
|
||||
try:
|
||||
interval = float(interval)
|
||||
except Exception:
|
||||
interval = 30.0
|
||||
interval = float(DEFAULT_RELOAD_INTERVAL_SEC)
|
||||
if interval <= 0:
|
||||
return
|
||||
if time.monotonic() - self._last_reload_at < interval:
|
||||
@@ -158,11 +156,11 @@ class TokenManager:
|
||||
|
||||
def _schedule_save(self):
|
||||
"""合并高频保存请求,减少写入开销"""
|
||||
delay_ms = get_config("token.save_delay_ms", 500)
|
||||
delay_ms = get_config("token.save_delay_ms", DEFAULT_SAVE_DELAY_MS)
|
||||
try:
|
||||
delay_ms = float(delay_ms)
|
||||
except Exception:
|
||||
delay_ms = 500
|
||||
delay_ms = float(DEFAULT_SAVE_DELAY_MS)
|
||||
self._save_delay = max(0.0, delay_ms / 1000.0)
|
||||
self._dirty = True
|
||||
if self._save_delay == 0:
|
||||
@@ -523,7 +521,16 @@ class TokenManager:
|
||||
# 收集需要刷新的 token
|
||||
to_refresh: List[TokenInfo] = []
|
||||
for pool in self.pools.values():
|
||||
interval_hours = _refresh_interval_hours_for_pool(pool.name)
|
||||
if pool.name == SUPER_POOL_NAME:
|
||||
interval_hours = get_config(
|
||||
"token.super_refresh_interval_hours",
|
||||
DEFAULT_SUPER_REFRESH_INTERVAL_HOURS,
|
||||
)
|
||||
else:
|
||||
interval_hours = get_config(
|
||||
"token.refresh_interval_hours",
|
||||
DEFAULT_REFRESH_INTERVAL_HOURS,
|
||||
)
|
||||
for token in pool:
|
||||
if token.need_refresh(interval_hours):
|
||||
to_refresh.append(token)
|
||||
@@ -535,7 +542,7 @@ class TokenManager:
|
||||
logger.info(f"Refresh check: found {len(to_refresh)} cooling tokens to refresh")
|
||||
|
||||
# 批量并发刷新
|
||||
semaphore = asyncio.Semaphore(REFRESH_CONCURRENCY)
|
||||
semaphore = asyncio.Semaphore(DEFAULT_REFRESH_CONCURRENCY)
|
||||
usage_service = UsageService()
|
||||
refreshed = 0
|
||||
recovered = 0
|
||||
@@ -606,15 +613,15 @@ class TokenManager:
|
||||
return {"recovered": False, "expired": False}
|
||||
|
||||
# 批量处理
|
||||
for i in range(0, len(to_refresh), REFRESH_BATCH_SIZE):
|
||||
batch = to_refresh[i : i + REFRESH_BATCH_SIZE]
|
||||
for i in range(0, len(to_refresh), DEFAULT_REFRESH_BATCH_SIZE):
|
||||
batch = to_refresh[i : i + DEFAULT_REFRESH_BATCH_SIZE]
|
||||
results = await asyncio.gather(*[_refresh_one(t) for t in batch])
|
||||
refreshed += len(batch)
|
||||
recovered += sum(r["recovered"] for r in results)
|
||||
expired += sum(r["expired"] for r in results)
|
||||
|
||||
# 批次间延迟
|
||||
if i + REFRESH_BATCH_SIZE < len(to_refresh):
|
||||
if i + DEFAULT_REFRESH_BATCH_SIZE < len(to_refresh):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self._save()
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<script src="/static/common/header.js?v=3"></script>
|
||||
<script src="/static/common/footer.js?v=1"></script>
|
||||
<script src="/static/common/toast.js"></script>
|
||||
<script src="/static/config/config.js?v=13"></script>
|
||||
<script src="/static/config/config.js?v=14"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+52
-54
@@ -26,6 +26,7 @@ const NUMERIC_FIELDS = new Set([
|
||||
'video_idle_timeout',
|
||||
'image_ws_blocked_seconds',
|
||||
'image_ws_final_min_bytes',
|
||||
'image_ws_medium_min_bytes',
|
||||
'nsfw_max_concurrent',
|
||||
'nsfw_batch_size',
|
||||
'nsfw_max_tokens'
|
||||
@@ -40,44 +41,60 @@ const LOCALE_MAP = {
|
||||
"image_format": { title: "图片格式", desc: "生成的图片格式(url 或 base64)。" },
|
||||
"video_format": { title: "视频格式", desc: "生成的视频格式(html 或 url,url 为处理后的链接)。" }
|
||||
},
|
||||
"grok": {
|
||||
"label": "Grok 设置",
|
||||
"network": {
|
||||
"label": "网络配置",
|
||||
"timeout": { title: "请求超时", desc: "请求 Grok 服务的超时时间(秒)。" },
|
||||
"base_proxy_url": { title: "基础代理 URL", desc: "代理请求到 Grok 官网的基础服务地址。" },
|
||||
"asset_proxy_url": { title: "资源代理 URL", desc: "代理请求到 Grok 官网的静态资源(图片/视频)地址。" }
|
||||
},
|
||||
"security": {
|
||||
"label": "反爬虫验证",
|
||||
"cf_clearance": { title: "CF Clearance", desc: "Cloudflare Clearance Cookie,用于绕过反爬虫验证。" },
|
||||
"browser": { title: "浏览器指纹", desc: "curl_cffi 浏览器指纹标识(如 chrome136)。" },
|
||||
"user_agent": { title: "User-Agent", desc: "HTTP 请求的 User-Agent 字符串,需与浏览器指纹匹配。" }
|
||||
},
|
||||
"chat": {
|
||||
"label": "对话配置",
|
||||
"temporary": { title: "临时对话", desc: "是否启用临时对话模式。" },
|
||||
"disable_memory": { title: "禁用记忆", desc: "禁用 Grok 记忆功能,以防止响应中出现不相关上下文" },
|
||||
"disable_memory": { title: "禁用记忆", desc: "禁用 Grok 记忆功能,以防止响应中出现不相关上下文。" },
|
||||
"stream": { title: "流式响应", desc: "是否默认启用流式输出。" },
|
||||
"thinking": { title: "思维链", desc: "是否启用模型思维链输出。" },
|
||||
"dynamic_statsig": { title: "动态指纹", desc: "是否启用动态生成 Statsig 值。" },
|
||||
"filter_tags": { title: "过滤标签", desc: "自动过滤 Grok 响应中的特殊标签。" },
|
||||
"timeout": { title: "超时时间", desc: "请求 Grok 服务的超时时间(秒)。" },
|
||||
"base_proxy_url": { title: "基础代理 URL", desc: "代理请求到 Grok 官网的基础服务地址。" },
|
||||
"asset_proxy_url": { title: "资源代理 URL", desc: "代理请求到 Grok 官网的静态资源(图片/视频)地址。" },
|
||||
"image_ws": { title: "Imagine WebSocket 生成", desc: "启用后 /v1/images/generations 走 WebSocket 直连。" },
|
||||
"image_ws_nsfw": { title: "Imagine NSFW", desc: "WS 请求里是否启用 NSFW。" },
|
||||
"image_ws_blocked_seconds": { title: "Imagine WebSocket Blocked 阈值", desc: "收到中等图后超过该秒数仍无最终图则判定 blocked。" },
|
||||
"image_ws_final_min_bytes": { title: "Imagine WebSocket 最终图最小字节", desc: "判定最终图的最小字节数(通常 JPG > 100KB)。" },
|
||||
"max_retry": { title: "最大重试", desc: "请求 Grok 服务失败时的最大重试次数。" },
|
||||
"filter_tags": { title: "过滤标签", desc: "自动过滤 Grok 响应中的特殊标签。" }
|
||||
},
|
||||
"retry": {
|
||||
"label": "重试策略",
|
||||
"max_retry": { title: "最大重试次数", desc: "请求 Grok 服务失败时的最大重试次数。" },
|
||||
"retry_status_codes": { title: "重试状态码", desc: "触发重试的 HTTP 状态码列表。" },
|
||||
"retry_backoff_base": { title: "退避基数", desc: "重试退避的基础延迟(秒)。" },
|
||||
"retry_backoff_factor": { title: "退避倍率", desc: "重试退避的指数放大系数。" },
|
||||
"retry_backoff_max": { title: "退避上限", desc: "单次重试等待的最大延迟(秒)。" },
|
||||
"retry_budget": { title: "退避预算", desc: "单次请求的最大重试总耗时(秒)。" },
|
||||
"retry_budget": { title: "退避预算", desc: "单次请求的最大重试总耗时(秒)。" }
|
||||
},
|
||||
"timeout": {
|
||||
"label": "超时配置",
|
||||
"stream_idle_timeout": { title: "流空闲超时", desc: "流式响应空闲超时(秒),超过将断开。" },
|
||||
"video_idle_timeout": { title: "视频空闲超时", desc: "视频生成空闲超时(秒),超过将断开。" },
|
||||
"cf_clearance": { title: "CF Clearance", desc: "" },
|
||||
"browser": { title: "curl_cffi", desc: "" },
|
||||
"user_agent": { title: "User-Agent", desc: "" }
|
||||
"video_idle_timeout": { title: "视频空闲超时", desc: "视频生成空闲超时(秒),超过将断开。" }
|
||||
},
|
||||
"image": {
|
||||
"label": "图片生成",
|
||||
"image_ws": { title: "WebSocket 生成", desc: "启用后 /v1/images/generations 走 WebSocket 直连。" },
|
||||
"image_ws_nsfw": { title: "NSFW 模式", desc: "WebSocket 请求是否启用 NSFW。" },
|
||||
"image_ws_blocked_seconds": { title: "Blocked 阈值", desc: "收到中等图后超过该秒数仍无最终图则判定 blocked。" },
|
||||
"image_ws_final_min_bytes": { title: "最终图最小字节", desc: "判定最终图的最小字节数(通常 JPG > 100KB)。" },
|
||||
"image_ws_medium_min_bytes": { title: "中等图最小字节", desc: "判定中等质量图的最小字节数。" }
|
||||
},
|
||||
"token": {
|
||||
"label": "Token 池设置",
|
||||
"label": "Token 池管理",
|
||||
"auto_refresh": { title: "自动刷新", desc: "是否开启 Token 自动刷新机制。" },
|
||||
"refresh_interval_hours": { title: "刷新间隔", desc: "Token 刷新的时间间隔(小时)。" },
|
||||
"refresh_interval_hours": { title: "刷新间隔", desc: "普通 Token 刷新的时间间隔(小时)。" },
|
||||
"super_refresh_interval_hours": { title: "Super 刷新间隔", desc: "Super Token 刷新的时间间隔(小时)。" },
|
||||
"fail_threshold": { title: "失败阈值", desc: "单个 Token 连续失败多少次后被标记为不可用。" },
|
||||
"save_delay_ms": { title: "保存延迟", desc: "Token 变更合并写入的延迟(毫秒)。" },
|
||||
"reload_interval_sec": { title: "一致性刷新", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" }
|
||||
"reload_interval_sec": { title: "同步间隔", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" }
|
||||
},
|
||||
"cache": {
|
||||
"label": "缓存设置",
|
||||
"label": "缓存管理",
|
||||
"enable_auto_clean": { title: "自动清理", desc: "是否启用缓存自动清理,开启后按上限自动回收。" },
|
||||
"limit_mb": { title: "清理阈值", desc: "缓存大小阈值(MB),超过阈值会触发清理。" }
|
||||
},
|
||||
@@ -97,11 +114,9 @@ const LOCALE_MAP = {
|
||||
}
|
||||
};
|
||||
|
||||
// 反爬虫验证字段(从 grok 中单独分组显示)
|
||||
const ANTIBOT_FIELDS = ['cf_clearance', 'browser', 'user_agent'];
|
||||
const ANTIBOT_GROUP = {
|
||||
label: "反爬虫验证",
|
||||
desc: "以下三个参数用于绕过 Cloudflare 反爬虫验证。配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致403。"
|
||||
// 配置部分说明(可选)
|
||||
const SECTION_DESCRIPTIONS = {
|
||||
"security": "配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致 403。"
|
||||
};
|
||||
|
||||
const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index]));
|
||||
@@ -250,24 +265,29 @@ function renderConfig(data) {
|
||||
const localeSection = LOCALE_MAP[section];
|
||||
const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null;
|
||||
|
||||
// 分离反爬虫字段和普通字段
|
||||
const allKeys = sortByOrder(Object.keys(items), keyOrder);
|
||||
const normalKeys = section === 'grok' ? allKeys.filter(k => !ANTIBOT_FIELDS.includes(k)) : allKeys;
|
||||
const antibotKeys = section === 'grok' ? allKeys.filter(k => ANTIBOT_FIELDS.includes(k)) : [];
|
||||
|
||||
// 渲染普通字段
|
||||
if (normalKeys.length > 0) {
|
||||
if (allKeys.length > 0) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'config-section';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.innerHTML = `<div class="config-section-title">${getSectionLabel(section)}</div>`;
|
||||
|
||||
// 添加部分说明(如果有)
|
||||
if (SECTION_DESCRIPTIONS[section]) {
|
||||
const descP = document.createElement('p');
|
||||
descP.className = 'text-[var(--accents-4)] text-sm mt-1 mb-4';
|
||||
descP.textContent = SECTION_DESCRIPTIONS[section];
|
||||
header.appendChild(descP);
|
||||
}
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'config-grid';
|
||||
|
||||
normalKeys.forEach(key => {
|
||||
allKeys.forEach(key => {
|
||||
const fieldCard = buildFieldCard(section, key, items[key]);
|
||||
grid.appendChild(fieldCard);
|
||||
});
|
||||
@@ -277,28 +297,6 @@ function renderConfig(data) {
|
||||
fragment.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染反爬虫验证分组(仅 grok section)
|
||||
if (antibotKeys.length > 0) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'config-section';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.innerHTML = `<div class="config-section-title">${ANTIBOT_GROUP.label}</div>
|
||||
<p class="text-[var(--accents-4)] text-sm mt-1 mb-4">${ANTIBOT_GROUP.desc}</p>`;
|
||||
card.appendChild(header);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'config-grid';
|
||||
|
||||
antibotKeys.forEach(key => {
|
||||
const fieldCard = buildFieldCard(section, key, items[key]);
|
||||
grid.appendChild(fieldCard);
|
||||
});
|
||||
|
||||
card.appendChild(grid);
|
||||
fragment.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
+110
-29
@@ -1,58 +1,139 @@
|
||||
[grok]
|
||||
temporary = true
|
||||
stream = true
|
||||
thinking = true
|
||||
dynamic_statsig = true
|
||||
filter_tags = ["xaiartifact","xai:tool_usage_card","grok:render"]
|
||||
timeout = 120
|
||||
base_proxy_url = ""
|
||||
asset_proxy_url = ""
|
||||
cf_clearance = ""
|
||||
# 浏览器指纹配置
|
||||
browser = "chrome136"
|
||||
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"
|
||||
disable_memory = true
|
||||
max_retry = 3
|
||||
retry_status_codes = [401,429,403]
|
||||
image_ws = true
|
||||
image_ws_blocked_seconds = 15
|
||||
image_ws_final_min_bytes = 100000
|
||||
image_ws_nsfw = true
|
||||
retry_backoff_base = 0.5
|
||||
retry_backoff_factor = 2.0
|
||||
retry_backoff_max = 30.0
|
||||
retry_budget = 90.0
|
||||
stream_idle_timeout = 120.0
|
||||
video_idle_timeout = 90.0
|
||||
|
||||
# ==================== 应用设置 ====================
|
||||
[app]
|
||||
# 应用访问地址(用于生成文件链接)
|
||||
app_url = "http://127.0.0.1:8000"
|
||||
# 后台管理密码
|
||||
app_key = "grok2api"
|
||||
# API 调用密钥(可选)
|
||||
api_key = ""
|
||||
# 生成图片的格式(url 或 base64)
|
||||
image_format = "url"
|
||||
# 生成视频的格式(html 或 url)
|
||||
video_format = "html"
|
||||
|
||||
|
||||
# ==================== 网络配置 ====================
|
||||
[network]
|
||||
# 请求超时时间(秒)
|
||||
timeout = 120
|
||||
# 基础代理地址(代理到 Grok 官网)
|
||||
base_proxy_url = ""
|
||||
# 资源代理地址(代理静态资源如图片/视频)
|
||||
asset_proxy_url = ""
|
||||
|
||||
|
||||
# ==================== 反爬虫验证 ====================
|
||||
[security]
|
||||
# Cloudflare Clearance Cookie
|
||||
cf_clearance = ""
|
||||
# curl_cffi 浏览器指纹
|
||||
browser = "chrome136"
|
||||
# User-Agent 字符串
|
||||
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"
|
||||
|
||||
|
||||
# ==================== 对话配置 ====================
|
||||
[chat]
|
||||
# 是否启用临时对话模式
|
||||
temporary = true
|
||||
# 是否禁用 Grok 记忆功能
|
||||
disable_memory = true
|
||||
# 是否默认启用流式响应
|
||||
stream = true
|
||||
# 是否启用思维链输出
|
||||
thinking = true
|
||||
# 是否动态生成 Statsig 指纹
|
||||
dynamic_statsig = true
|
||||
# 过滤的特殊标签列表
|
||||
filter_tags = ["xaiartifact","xai:tool_usage_card","grok:render"]
|
||||
|
||||
|
||||
# ==================== 重试策略 ====================
|
||||
[retry]
|
||||
# 最大重试次数
|
||||
max_retry = 3
|
||||
# 触发重试的 HTTP 状态码
|
||||
retry_status_codes = [401,429,403]
|
||||
# 退避基础延迟(秒)
|
||||
retry_backoff_base = 0.5
|
||||
# 退避倍率
|
||||
retry_backoff_factor = 2.0
|
||||
# 单次重试最大延迟(秒)
|
||||
retry_backoff_max = 30.0
|
||||
# 总重试预算时间(秒)
|
||||
retry_budget = 90.0
|
||||
|
||||
|
||||
# ==================== 超时配置 ====================
|
||||
[timeout]
|
||||
# 流式响应空闲超时(秒)
|
||||
stream_idle_timeout = 120.0
|
||||
# 视频生成空闲超时(秒)
|
||||
video_idle_timeout = 90.0
|
||||
|
||||
|
||||
# ==================== 图片生成 ====================
|
||||
[image]
|
||||
# 是否启用 WebSocket 直连生成图片
|
||||
image_ws = true
|
||||
# WebSocket 生成时是否启用 NSFW
|
||||
image_ws_nsfw = true
|
||||
# 收到中等质量图后等待最终图的超时秒数
|
||||
image_ws_blocked_seconds = 15
|
||||
# 判定为最终图的最小字节数
|
||||
image_ws_final_min_bytes = 100000
|
||||
# 判定为中等质量图的最小字节数
|
||||
image_ws_medium_min_bytes = 30000
|
||||
|
||||
|
||||
# ==================== Token 池管理 ====================
|
||||
[token]
|
||||
# 是否启用 Token 自动刷新
|
||||
auto_refresh = true
|
||||
# 普通 Token 刷新间隔(小时)
|
||||
refresh_interval_hours = 8
|
||||
# Super Token 刷新间隔(小时)
|
||||
super_refresh_interval_hours = 2
|
||||
# Token 连续失败阈值
|
||||
fail_threshold = 5
|
||||
# Token 变更保存延迟(毫秒)
|
||||
save_delay_ms = 500
|
||||
# 多 worker 状态同步间隔(秒)
|
||||
reload_interval_sec = 30
|
||||
|
||||
|
||||
# ==================== 缓存管理 ====================
|
||||
[cache]
|
||||
# 是否启用自动清理
|
||||
enable_auto_clean = true
|
||||
# 缓存大小上限(MB)
|
||||
limit_mb = 1024
|
||||
|
||||
|
||||
# ==================== 并发性能 ====================
|
||||
[performance]
|
||||
# Assets 批量处理并发上限
|
||||
assets_max_concurrent = 25
|
||||
media_max_concurrent = 50
|
||||
usage_max_concurrent = 25
|
||||
# Assets 批量删除大小
|
||||
assets_delete_batch_size = 10
|
||||
# Assets 单批处理数量
|
||||
assets_batch_size = 10
|
||||
# Assets 单次最大处理数量
|
||||
assets_max_tokens = 1000
|
||||
|
||||
# Media 生成并发上限
|
||||
media_max_concurrent = 50
|
||||
|
||||
# Token 用量刷新并发上限
|
||||
usage_max_concurrent = 25
|
||||
# Token 用量刷新批次大小
|
||||
usage_batch_size = 50
|
||||
# Token 用量单次最大数量
|
||||
usage_max_tokens = 1000
|
||||
|
||||
# NSFW 批量开启并发上限
|
||||
nsfw_max_concurrent = 10
|
||||
# NSFW 批量开启批次大小
|
||||
nsfw_batch_size = 50
|
||||
# NSFW 单次最大数量
|
||||
nsfw_max_tokens = 1000
|
||||
+39
-25
@@ -1,26 +1,3 @@
|
||||
[grok]
|
||||
temporary = true
|
||||
stream = true
|
||||
thinking = true
|
||||
dynamic_statsig = true
|
||||
filter_tags = ["xaiartifact","xai:tool_usage_card","grok:render"]
|
||||
timeout = 120
|
||||
base_proxy_url = ""
|
||||
asset_proxy_url = ""
|
||||
cf_clearance = ""
|
||||
max_retry = 3
|
||||
retry_status_codes = [401,429,403]
|
||||
image_ws = true
|
||||
image_ws_blocked_seconds = 15
|
||||
image_ws_final_min_bytes = 100000
|
||||
image_ws_nsfw = true
|
||||
retry_backoff_base = 0.5
|
||||
retry_backoff_factor = 2.0
|
||||
retry_backoff_max = 30.0
|
||||
retry_budget = 90.0
|
||||
stream_idle_timeout = 120.0
|
||||
video_idle_timeout = 90.0
|
||||
|
||||
[app]
|
||||
app_url = "http://127.0.0.1:8000"
|
||||
app_key = "grok2api"
|
||||
@@ -28,6 +5,43 @@ api_key = ""
|
||||
image_format = "url"
|
||||
video_format = "html"
|
||||
|
||||
[network]
|
||||
timeout = 120
|
||||
base_proxy_url = ""
|
||||
asset_proxy_url = ""
|
||||
|
||||
[security]
|
||||
cf_clearance = ""
|
||||
browser = "chrome136"
|
||||
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"
|
||||
|
||||
[chat]
|
||||
temporary = true
|
||||
disable_memory = true
|
||||
stream = true
|
||||
thinking = true
|
||||
dynamic_statsig = true
|
||||
filter_tags = ["xaiartifact","xai:tool_usage_card","grok:render"]
|
||||
|
||||
[retry]
|
||||
max_retry = 3
|
||||
retry_status_codes = [401,429,403]
|
||||
retry_backoff_base = 0.5
|
||||
retry_backoff_factor = 2.0
|
||||
retry_backoff_max = 30.0
|
||||
retry_budget = 90.0
|
||||
|
||||
[timeout]
|
||||
stream_idle_timeout = 120.0
|
||||
video_idle_timeout = 90.0
|
||||
|
||||
[image]
|
||||
image_ws = true
|
||||
image_ws_nsfw = true
|
||||
image_ws_blocked_seconds = 15
|
||||
image_ws_final_min_bytes = 100000
|
||||
image_ws_medium_min_bytes = 30000
|
||||
|
||||
[token]
|
||||
auto_refresh = true
|
||||
refresh_interval_hours = 8
|
||||
@@ -42,11 +56,11 @@ limit_mb = 1024
|
||||
|
||||
[performance]
|
||||
assets_max_concurrent = 25
|
||||
media_max_concurrent = 50
|
||||
usage_max_concurrent = 25
|
||||
assets_delete_batch_size = 10
|
||||
assets_batch_size = 10
|
||||
assets_max_tokens = 1000
|
||||
media_max_concurrent = 50
|
||||
usage_max_concurrent = 25
|
||||
usage_batch_size = 50
|
||||
usage_max_tokens = 1000
|
||||
nsfw_max_concurrent = 10
|
||||
|
||||
+31
-23
@@ -1,6 +1,6 @@
|
||||
# Grok2API
|
||||
|
||||
[中文](README.md) | **English**
|
||||
[中文](../README.md) | **English**
|
||||
|
||||
> [!NOTE]
|
||||
> This project is for learning and research only. You must comply with Grok's Terms of Use and applicable laws. Do not use it for illegal purposes.
|
||||
@@ -194,6 +194,10 @@ Config file: `data/config.toml`
|
||||
> In production or behind a reverse proxy, make sure `app.app_url` is set to the public URL.
|
||||
> Otherwise file links may be incorrect or return 403.
|
||||
|
||||
> [!TIP]
|
||||
> **v2.0 Config Upgrade**: Existing users will have their config **auto-migrated** to the new structure upon update.
|
||||
> Custom values from the old `[grok]` section will be automatically mapped to the corresponding new sections.
|
||||
|
||||
| Module | Field | Key | Description | Default |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **app** | `app_url` | App URL | External access URL for Grok2API (used for file links). | `http://127.0.0.1:8000` |
|
||||
@@ -201,46 +205,50 @@ Config file: `data/config.toml`
|
||||
| | `api_key` | API key | Token for calling Grok2API (optional). | `""` |
|
||||
| | `image_format` | Image format | Output image format (`url` or `base64`). | `url` |
|
||||
| | `video_format` | Video format | Output video format (html tag or processed url). | `html` |
|
||||
| **grok** | `temporary` | Temporary chat | Enable temporary conversation mode. | `true` |
|
||||
| **network** | `timeout` | Request timeout | Timeout for Grok requests (seconds). | `120` |
|
||||
| | `base_proxy_url` | Base proxy URL | Base service address proxying Grok official site. | `""` |
|
||||
| | `asset_proxy_url` | Asset proxy URL | Proxy URL for Grok static assets (images/videos). | `""` |
|
||||
| **security** | `cf_clearance` | CF Clearance | Cloudflare clearance cookie for bypassing anti-bot. | `""` |
|
||||
| | `browser` | Browser fingerprint | curl_cffi browser fingerprint (e.g. chrome136). | `chrome136` |
|
||||
| | `user_agent` | User-Agent | HTTP User-Agent string. | `Mozilla/5.0 (Macintosh; ...)` |
|
||||
| **chat** | `temporary` | Temporary chat | Enable temporary conversation mode. | `true` |
|
||||
| | `disable_memory` | Disable memory | Disable Grok memory to prevent irrelevant context. | `true` |
|
||||
| | `stream` | Streaming | Enable streaming by default. | `true` |
|
||||
| | `thinking` | Thinking chain | Enable model thinking output. | `true` |
|
||||
| | `dynamic_statsig` | Dynamic fingerprint | Enable dynamic Statsig value generation. | `true` |
|
||||
| | `filter_tags` | Filter tags | Auto-filter special tags in Grok responses. | `["xaiartifact", "xai:tool_usage_card", "grok:render"]` |
|
||||
| | `timeout` | Timeout | Timeout for Grok requests (seconds). | `120` |
|
||||
| | `base_proxy_url` | Base proxy URL | Base service address proxying Grok official site. | `""` |
|
||||
| | `asset_proxy_url` | Asset proxy URL | Proxy URL for Grok static assets (images/videos). | `""` |
|
||||
| | `cf_clearance` | CF Clearance | Cloudflare clearance cookie for verification. | `""` |
|
||||
| | `max_retry` | Max retries | Max retries on Grok request failure. | `3` |
|
||||
| **retry** | `max_retry` | Max retries | Max retries on Grok request failure. | `3` |
|
||||
| | `retry_status_codes` | Retry status codes | HTTP status codes that trigger retry. | `[401, 429, 403]` |
|
||||
| | `retry_backoff_base` | Backoff base | Base delay for retry backoff (seconds). | `0.5` |
|
||||
| | `retry_backoff_factor` | Backoff factor | Exponential multiplier for retry backoff. | `2.0` |
|
||||
| | `retry_backoff_max` | Backoff max | Max wait per retry (seconds). | `30.0` |
|
||||
| | `retry_budget` | Backoff budget | Max total retry time per request (seconds). | `90.0` |
|
||||
| | `stream_idle_timeout` | Stream idle timeout | Idle timeout for streaming responses (seconds). | `45.0` |
|
||||
| **timeout** | `stream_idle_timeout` | Stream idle timeout | Idle timeout for streaming responses (seconds). | `120.0` |
|
||||
| | `video_idle_timeout` | Video idle timeout | Idle timeout for video generation (seconds). | `90.0` |
|
||||
| | `image_ws` | Image WS generation | When enabled, `/v1/images/generations` uses WebSocket. | `false` |
|
||||
| | `image_ws_blocked_seconds` | WS blocked threshold | Mark blocked if no final image after this many seconds post-medium. | `15` |
|
||||
| | `image_ws_final_min_bytes` | WS final min bytes | Minimum bytes to treat an image as final (JPG usually > 100KB). | `100000` |
|
||||
| | `image_ws_nsfw` | WS NSFW | Enable NSFW in WS requests. | `true` |
|
||||
| **image** | `image_ws` | WebSocket generation | When enabled, `/v1/images/generations` uses WebSocket. | `true` |
|
||||
| | `image_ws_nsfw` | NSFW mode | Enable NSFW in WebSocket requests. | `true` |
|
||||
| | `image_ws_blocked_seconds` | Blocked threshold | Mark blocked if no final image after this many seconds post-medium. | `15` |
|
||||
| | `image_ws_final_min_bytes` | Final min bytes | Minimum bytes to treat an image as final (JPG usually > 100KB). | `100000` |
|
||||
| | `image_ws_medium_min_bytes` | Medium min bytes | Minimum bytes for medium quality image. | `30000` |
|
||||
| **token** | `auto_refresh` | Auto refresh | Enable automatic token refresh. | `true` |
|
||||
| | `refresh_interval_hours` | Refresh interval | Token refresh interval (hours). | `8` |
|
||||
| | `refresh_interval_hours` | Refresh interval | Regular token refresh interval (hours). | `8` |
|
||||
| | `super_refresh_interval_hours` | Super refresh interval | Super token refresh interval (hours). | `2` |
|
||||
| | `fail_threshold` | Failure threshold | Consecutive failures before a token is disabled. | `5` |
|
||||
| | `save_delay_ms` | Save delay | Debounced save delay for token changes (ms). | `500` |
|
||||
| | `reload_interval_sec` | Consistency refresh | Token state refresh interval in multi-worker setups (sec). | `30` |
|
||||
| | `reload_interval_sec` | Sync interval | Token state refresh interval in multi-worker setups (sec). | `30` |
|
||||
| **cache** | `enable_auto_clean` | Auto clean | Enable cache auto clean; cleanup when exceeding limit. | `true` |
|
||||
| | `limit_mb` | Cleanup threshold | Cache size threshold (MB) that triggers cleanup. | `1024` |
|
||||
| **performance** | `nsfw_max_concurrent` | NSFW enable concurrency | Concurrency cap for enabling NSFW in batch. Recommended 10. | `10` |
|
||||
| **performance** | `media_max_concurrent` | Media concurrency | Concurrency cap for video/media generation. Recommended 50. | `50` |
|
||||
| | `assets_max_concurrent` | Assets concurrency | Concurrency cap for batch asset find/delete. Recommended 25. | `25` |
|
||||
| | `assets_batch_size` | Assets batch size | Batch size for asset find/delete. Recommended 10. | `10` |
|
||||
| | `assets_max_tokens` | Assets max tokens | Max tokens per asset find/delete batch. Recommended 1000. | `1000` |
|
||||
| | `assets_delete_batch_size` | Assets delete batch | Batch concurrency for single-account asset deletion. Recommended 10. | `10` |
|
||||
| | `usage_max_concurrent` | Token refresh concurrency | Concurrency cap for batch usage refresh. Recommended 25. | `25` |
|
||||
| | `usage_batch_size` | Token refresh batch size | Batch size for usage refresh. Recommended 50. | `50` |
|
||||
| | `usage_max_tokens` | Token refresh max tokens | Max tokens per usage refresh batch. Recommended 1000. | `1000` |
|
||||
| | `nsfw_max_concurrent` | NSFW enable concurrency | Concurrency cap for enabling NSFW in batch. Recommended 10. | `10` |
|
||||
| | `nsfw_batch_size` | NSFW enable batch size | Batch size for enabling NSFW. Recommended 50. | `50` |
|
||||
| | `nsfw_max_tokens` | NSFW enable max tokens | Max tokens per NSFW batch to avoid mistakes. Recommended 1000. | `1000` |
|
||||
| | `usage_max_concurrent` | Token usage refresh concurrency | Concurrency cap for batch usage refresh. Recommended 25. | `25` |
|
||||
| | `usage_batch_size` | Token usage refresh batch size | Batch size for usage refresh. Recommended 50. | `50` |
|
||||
| | `usage_max_tokens` | Token usage refresh max tokens | Max tokens per usage refresh batch. Recommended 1000. | `1000` |
|
||||
| | `assets_max_concurrent` | Online assets find/delete concurrency | Concurrency cap for online asset find/delete. Recommended 25. | `25` |
|
||||
| | `assets_batch_size` | Online assets find/delete batch size | Batch size for online asset find/delete. Recommended 10. | `10` |
|
||||
| | `assets_max_tokens` | Online assets find/delete max tokens | Max tokens per online asset find/delete batch. Recommended 1000. | `1000` |
|
||||
| | `assets_delete_batch_size` | Online assets delete batch | Batch concurrency for online asset deletion. Recommended 10. | `10` |
|
||||
| | `media_max_concurrent` | Media concurrency | Concurrency cap for video/media generation. Recommended 50. | `50` |
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
@@ -41,17 +41,21 @@ setup_logging(
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 1. 加载配置
|
||||
from app.core.config import config
|
||||
# 1. 注册服务默认配置
|
||||
from app.core.config import config, register_defaults
|
||||
from app.services.grok.defaults import get_grok_defaults
|
||||
|
||||
register_defaults(get_grok_defaults())
|
||||
|
||||
# 2. 加载配置
|
||||
await config.load()
|
||||
|
||||
# 2. 启动服务显示
|
||||
# 3. 启动服务显示
|
||||
logger.info("Starting Grok2API...")
|
||||
logger.info(f"Platform: {platform.system()} {platform.release()}")
|
||||
logger.info(f"Python: {sys.version.split()[0]}")
|
||||
|
||||
# 3. 启动 Token 刷新调度器
|
||||
# 4. 启动 Token 刷新调度器
|
||||
refresh_enabled = get_config("token.auto_refresh", True)
|
||||
if refresh_enabled:
|
||||
basic_interval = get_config("token.refresh_interval_hours", 8)
|
||||
|
||||
@@ -26,3 +26,8 @@ dependencies = [
|
||||
"tomli>=2.4.0",
|
||||
"uvicorn>=0.37.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.15.0",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
# Grok2API
|
||||
|
||||
**中文** | [English](README.en.md)
|
||||
**中文** | [English](docs/README.en.md)
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目仅供学习与研究,使用者必须在遵循 Grok 的 **使用条款** 以及 **法律法规** 的情况下使用,不得用于非法用途。
|
||||
@@ -223,6 +223,10 @@ curl http://localhost:8000/v1/images/edits \
|
||||
> 生产环境或反向代理部署时,请确保 `app.app_url` 配置为对外可访问的完整 URL,
|
||||
> 否则可能出现文件访问链接不正确或 403 等问题。
|
||||
|
||||
> [!TIP]
|
||||
> **v2.0 配置结构升级**:旧版本用户更新后,配置会**自动迁移**到新结构,无需手动修改。
|
||||
> 旧的 `[grok]` 配置节中的自定义值会自动映射到对应的新配置节。
|
||||
|
||||
| 模块 | 字段 | 配置名 | 说明 | 默认值 |
|
||||
| :-------------------- | :--------------------------- | :----------- | :--------------------------------------------------- | :-------------------------------------------------------- |
|
||||
| **app** | `app_url` | 应用地址 | 当前 Grok2API 服务的外部访问 URL,用于文件链接访问。 | `http://127.0.0.1:8000` |
|
||||
@@ -230,46 +234,50 @@ curl http://localhost:8000/v1/images/edits \
|
||||
| | `api_key` | API 密钥 | 调用 Grok2API 服务的 Token(可选)。 | `""` |
|
||||
| | `image_format` | 图片格式 | 生成的图片格式(url 或 base64)。 | `url` |
|
||||
| | `video_format` | 视频格式 | 生成的视频格式(html 或 url,url 为处理后的链接)。 | `html` |
|
||||
| **grok** | `temporary` | 临时对话 | 是否启用临时对话模式。 | `true` |
|
||||
| **network** | `timeout` | 请求超时 | 请求 Grok 服务的超时时间(秒)。 | `120` |
|
||||
| | `base_proxy_url` | 基础代理 URL | 代理请求到 Grok 官网的基础服务地址。 | `""` |
|
||||
| | `asset_proxy_url` | 资源代理 URL | 代理请求到 Grok 官网的静态资源(图片/视频)地址。 | `""` |
|
||||
| **security** | `cf_clearance` | CF Clearance | Cloudflare 验证 Cookie,用于绕过反爬虫验证。 | `""` |
|
||||
| | `browser` | 浏览器指纹 | curl_cffi 浏览器指纹标识(如 chrome136)。 | `chrome136` |
|
||||
| | `user_agent` | User-Agent | HTTP 请求的 User-Agent 字符串。 | `Mozilla/5.0 (Macintosh; ...)` |
|
||||
| **chat** | `temporary` | 临时对话 | 是否启用临时对话模式。 | `true` |
|
||||
| | `disable_memory` | 禁用记忆 | 禁用 Grok 记忆功能,防止响应中出现不相关上下文。 | `true` |
|
||||
| | `stream` | 流式响应 | 是否默认启用流式输出。 | `true` |
|
||||
| | `thinking` | 思维链 | 是否启用模型思维链输出。 | `true` |
|
||||
| | `dynamic_statsig` | 动态指纹 | 是否启用动态生成 Statsig 值。 | `true` |
|
||||
| | `filter_tags` | 过滤标签 | 自动过滤 Grok 响应中的特殊标签。 | `["xaiartifact", "xai:tool_usage_card", "grok:render"]` |
|
||||
| | `timeout` | 超时时间 | 请求 Grok 服务的超时时间(秒)。 | `120` |
|
||||
| | `base_proxy_url` | 基础代理 URL | 代理请求到 Grok 官网的基础服务地址。 | `""` |
|
||||
| | `asset_proxy_url` | 资源代理 URL | 代理请求到 Grok 官网的静态资源(图片/视频)地址。 | `""` |
|
||||
| | `cf_clearance` | CF Clearance | Cloudflare 验证 Cookie,用于验证 Cloudflare 的验证。 | `""` |
|
||||
| | `max_retry` | 最大重试 | 请求 Grok 服务失败时的最大重试次数。 | `3` |
|
||||
| **retry** | `max_retry` | 最大重试 | 请求 Grok 服务失败时的最大重试次数。 | `3` |
|
||||
| | `retry_status_codes` | 重试状态码 | 触发重试的 HTTP 状态码列表。 | `[401, 429, 403]` |
|
||||
| | `retry_backoff_base` | 退避基数 | 重试退避的基础延迟(秒)。 | `0.5` |
|
||||
| | `retry_backoff_factor` | 退避倍率 | 重试退避的指数放大系数。 | `2.0` |
|
||||
| | `retry_backoff_max` | 退避上限 | 单次重试等待的最大延迟(秒)。 | `30.0` |
|
||||
| | `retry_budget` | 退避预算 | 单次请求的最大重试总耗时(秒)。 | `90.0` |
|
||||
| | `stream_idle_timeout` | 流空闲超时 | 流式响应空闲超时(秒),超过将断开。 | `45.0` |
|
||||
| **timeout** | `stream_idle_timeout` | 流空闲超时 | 流式响应空闲超时(秒),超过将断开。 | `120.0` |
|
||||
| | `video_idle_timeout` | 视频空闲超时 | 视频生成空闲超时(秒),超过将断开。 | `90.0` |
|
||||
| | `image_ws` | 图片 WS 生成 | 启用后 `/v1/images/generations` 走 WebSocket 直连。 | `false` |
|
||||
| | `image_ws_blocked_seconds` | WS Blocked 阈值 | 收到中等图后超过该秒数仍无最终图则判定 blocked。 | `15` |
|
||||
| | `image_ws_final_min_bytes` | WS 最终图最小字节 | 判定最终图的最小字节数(通常 JPG > 100KB)。 | `100000` |
|
||||
| | `image_ws_nsfw` | WS NSFW | WS 请求里是否启用 NSFW。 | `true` |
|
||||
| **image** | `image_ws` | WebSocket 生成 | 启用后 `/v1/images/generations` 走 WebSocket 直连。| `true` |
|
||||
| | `image_ws_nsfw` | NSFW 模式 | WebSocket 请求是否启用 NSFW。 | `true` |
|
||||
| | `image_ws_blocked_seconds` | Blocked 阈值 | 收到中等图后超过该秒数仍无最终图则判定 blocked。 | `15` |
|
||||
| | `image_ws_final_min_bytes` | 最终图最小字节 | 判定最终图的最小字节数(通常 JPG > 100KB)。 | `100000` |
|
||||
| | `image_ws_medium_min_bytes`| 中等图最小字节 | 判定中等质量图的最小字节数。 | `30000` |
|
||||
| **token** | `auto_refresh` | 自动刷新 | 是否开启 Token 自动刷新机制。 | `true` |
|
||||
| | `refresh_interval_hours` | 刷新间隔 | Token 刷新的时间间隔(小时)。 | `8` |
|
||||
| | `super_refresh_interval_hours` | Super 刷新间隔 | Super Token 刷新的时间间隔(小时)。 | `2` |
|
||||
| | `refresh_interval_hours` | 刷新间隔 | 普通 Token 刷新的时间间隔(小时)。 | `8` |
|
||||
| | `super_refresh_interval_hours` | Super 刷新间隔 | Super Token 刷新的时间间隔(小时)。 | `2` |
|
||||
| | `fail_threshold` | 失败阈值 | 单个 Token 连续失败多少次后被标记为不可用。 | `5` |
|
||||
| | `save_delay_ms` | 保存延迟 | Token 变更合并写入的延迟(毫秒)。 | `500` |
|
||||
| | `reload_interval_sec` | 一致性刷新 | 多 worker 场景下 Token 状态刷新间隔(秒)。 | `30` |
|
||||
| | `reload_interval_sec` | 同步间隔 | 多 worker 场景下 Token 状态刷新间隔(秒)。 | `30` |
|
||||
| **cache** | `enable_auto_clean` | 自动清理 | 是否启用缓存自动清理,开启后按上限自动回收。 | `true` |
|
||||
| | `limit_mb` | 清理阈值 | 缓存大小阈值(MB),超过阈值会触发清理。 | `1024` |
|
||||
| **performance** | `nsfw_max_concurrent` | 开启 NSFW 模式并发上限 | 批量开启 NSFW 模式时的并发请求上限。推荐 10。 | `10` |
|
||||
| | `nsfw_batch_size` | 开启 NSFW 模式批次大小 | 批量开启 NSFW 模式的单批处理数量。推荐 50。 | `50` |
|
||||
| | `nsfw_max_tokens` | 开启 NSFW 模式最大数量 | 单次批量开启 NSFW 的 Token 数量上限,防止误操作。推荐 1000。 | `1000` |
|
||||
| | `usage_max_concurrent` | Token 用量刷新并发上限 | 批量刷新 Token 用量时的并发请求上限。推荐 25。 | `25` |
|
||||
| | `usage_batch_size` | Token 用量刷新批次大小 | 批量刷新 Token 用量的单批处理数量。推荐 50。 | `50` |
|
||||
| | `usage_max_tokens` | Token 用量刷新最大数量 | 批量刷新 Token 用量的单次处理数量上限。推荐 1000。 | `1000` |
|
||||
| | `assets_max_concurrent` | 在线资产查找/删除并发上限 | 在线资产查找/删除时的并发请求上限。推荐 25。 | `25` |
|
||||
| | `assets_batch_size` | 在线资产查找/删除批次大小 | 在线资产查找/删除的单批处理数量。推荐 10。 | `10` |
|
||||
| | `assets_max_tokens` | 在线资产查找/删除最大数量 | 在线资产查找/删除的单次处理数量上限。推荐 1000。 | `1000` |
|
||||
| | `assets_delete_batch_size` | 在线资产删除批量 | 在线资产删除单批并发数量。推荐 10。 | `10` |
|
||||
| | `media_max_concurrent` | 媒体并发上限 | 视频/媒体生成请求的并发上限。推荐 50。 | `50` |
|
||||
| **performance** | `media_max_concurrent` | Media 并发上限 | 视频/媒体生成请求的并发上限。推荐 50。 | `50` |
|
||||
| | `assets_max_concurrent` | Assets 并发上限 | 批量查找/删除资产时的并发请求上限。推荐 25。 | `25` |
|
||||
| | `assets_batch_size` | Assets 批次大小 | 批量查找/删除资产时的单批处理数量。推荐 10。 | `10` |
|
||||
| | `assets_max_tokens` | Assets 最大数量 | 单次批量查找/删除资产时的处理数量上限。推荐 1000。 | `1000` |
|
||||
| | `assets_delete_batch_size` | Assets 删除批量 | 单账号批量删除资产时的单批并发数量。推荐 10。 | `10` |
|
||||
| | `usage_max_concurrent` | Token 刷新并发上限 | 批量刷新 Token 用量时的并发请求上限。推荐 25。 | `25` |
|
||||
| | `usage_batch_size` | Token 刷新批次大小 | 批量刷新 Token 用量的单批处理数量。推荐 50。 | `50` |
|
||||
| | `usage_max_tokens` | Token 刷新最大数量 | 单次批量刷新 Token 用量时的处理数量上限。推荐 1000。| `1000` |
|
||||
| | `nsfw_max_concurrent` | NSFW 开启并发上限 | 批量开启 NSFW 模式时的并发请求上限。推荐 10。 | `10` |
|
||||
| | `nsfw_batch_size` | NSFW 开启批次大小 | 批量开启 NSFW 模式的单批处理数量。推荐 50。 | `50` |
|
||||
| | `nsfw_max_tokens` | NSFW 开启最大数量 | 单次批量开启 NSFW 的 Token 数量上限。推荐 1000。 | `1000` |
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ load_dotenv(ROOT / ".env")
|
||||
from app.core.config import config
|
||||
from app.core.logger import setup_logging
|
||||
from app.services.grok.services.chat import GrokChatService
|
||||
from app.services.grok.processors.processor import CollectProcessor
|
||||
from app.services.grok.processors import CollectProcessor
|
||||
from app.services.grok.services.usage import UsageService
|
||||
|
||||
|
||||
|
||||
@@ -443,6 +443,11 @@ dependencies = [
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=25.1.0" },
|
||||
@@ -467,6 +472,9 @@ requires-dist = [
|
||||
{ name = "uvicorn", specifier = ">=0.37.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.15.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -970,6 +978,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.46"
|
||||
|
||||
Reference in new issue
Block a user