diff --git a/app/api/v1/admin.py b/app/api/v1/admin.py
index 09346ee..ab12b46 100644
--- a/app/api/v1/admin.py
+++ b/app/api/v1/admin.py
@@ -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))
diff --git a/app/api/v1/image.py b/app/api/v1/image.py
index 929bc3f..ec207c1 100644
--- a/app/api/v1/image.py
+++ b/app/api/v1/image.py
@@ -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},
},
}
diff --git a/app/core/auth.py b/app/core/auth.py
index e17804c..e6bb3c3 100644
--- a/app/core/auth.py
+++ b/app/core/auth.py
@@ -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(
diff --git a/app/core/config.py b/app/core/config.py
index 641b1cb..bec8714 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -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"]
diff --git a/app/services/grok/defaults.py b/app/services/grok/defaults.py
new file mode 100644
index 0000000..03f1c10
--- /dev/null
+++ b/app/services/grok/defaults.py
@@ -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"]
diff --git a/app/services/grok/models/model.py b/app/services/grok/models/model.py
index 1a8a018..e7cd8d6 100644
--- a/app/services/grok/models/model.py
+++ b/app/services/grok/models/model.py
@@ -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",
diff --git a/app/services/grok/processors/__init__.py b/app/services/grok/processors/__init__.py
index e69de29..04773f6 100644
--- a/app/services/grok/processors/__init__.py
+++ b/app/services/grok/processors/__init__.py
@@ -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",
+]
diff --git a/app/services/grok/processors/base.py b/app/services/grok/processors/base.py
new file mode 100644
index 0000000..76b4838
--- /dev/null
+++ b/app/services/grok/processors/base.py
@@ -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",
+]
diff --git a/app/services/grok/processors/chat_processors.py b/app/services/grok/processors/chat_processors.py
new file mode 100644
index 0000000..8f7c153
--- /dev/null
+++ b/app/services/grok/processors/chat_processors.py
@@ -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 中的特殊标签(如 ...),支持跨 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("\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("\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("\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"]
diff --git a/app/services/grok/processors/image_processors.py b/app/services/grok/processors/image_processors.py
new file mode 100644
index 0000000..2dac0bd
--- /dev/null
+++ b/app/services/grok/processors/image_processors.py
@@ -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"]
diff --git a/app/services/grok/processors/image_ws_processors.py b/app/services/grok/processors/image_ws_processors.py
new file mode 100644
index 0000000..2074ec2
--- /dev/null
+++ b/app/services/grok/processors/image_ws_processors.py
@@ -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"]
diff --git a/app/services/grok/processors/processor.py b/app/services/grok/processors/processor.py
deleted file mode 100644
index ae3dc41..0000000
--- a/app/services/grok/processors/processor.py
+++ /dev/null
@@ -1,1257 +0,0 @@
-"""
-OpenAI 响应格式处理器
-"""
-
-import asyncio
-import base64
-import time
-import uuid
-import random
-import re
-from pathlib import Path
-import orjson
-from typing import Any, AsyncGenerator, Optional, AsyncIterable, List, TypeVar, Dict
-
-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 app.services.grok.services.assets import DownloadService
-
-
-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
-
-
-T = TypeVar("T")
-
-
-class StreamIdleTimeoutError(Exception):
- """流空闲超时错误"""
-
- def __init__(self, idle_seconds: float):
- self.idle_seconds = idle_seconds
- super().__init__(f"Stream idle timeout after {idle_seconds}s")
-
-
-async def _with_idle_timeout(
- iterable: AsyncIterable[T], idle_timeout: float, model: str = ""
-) -> AsyncGenerator[T, None]:
- """
- 包装异步迭代器,添加空闲超时检测
-
- Args:
- iterable: 原始异步迭代器
- idle_timeout: 空闲超时时间(秒),0 表示禁用
- model: 模型名称(用于日志)
-
- Yields:
- 原始迭代器的元素
-
- Raises:
- StreamIdleTimeoutError: 当空闲超时时
- """
- 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
-
-
-ASSET_URL = "https://assets.grok.com/"
-
-
-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}"
-
- def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
- """构建 SSE 响应 (StreamProcessor 通用)"""
- if not hasattr(self, "response_id"):
- self.response_id = None
- if not hasattr(self, "fingerprint"):
- self.fingerprint = ""
-
- delta = {}
- if role:
- delta["role"] = role
- delta["content"] = ""
- elif content:
- delta["content"] = content
-
- chunk = {
- "id": self.response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}",
- "object": "chat.completion.chunk",
- "created": self.created,
- "model": self.model,
- "system_fingerprint": self.fingerprint
- if hasattr(self, "fingerprint")
- else "",
- "choices": [
- {"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}
- ],
- }
- return f"data: {orjson.dumps(chunk).decode()}\n\n"
-
-
-class StreamProcessor(BaseProcessor):
- """流式响应处理器"""
-
- def __init__(self, model: str, token: str = "", think: bool = None):
- super().__init__(model, token)
- self.response_id: Optional[str] = None
- self.fingerprint: str = ""
- self.think_opened: bool = False
- self.role_sent: bool = False
- self.filter_tags = get_config("grok.filter_tags", [])
- self.image_format = get_config("app.image_format", "url")
- # 用于过滤跨 token 的标签
- self._tag_buffer: str = ""
- self._in_filter_tag: bool = False
-
- if think is None:
- self.show_think = get_config("grok.thinking", False)
- else:
- self.show_think = think
-
- def _filter_token(self, token: str) -> str:
- """
- 过滤 token 中的特殊标签(如 ...)
- 支持跨 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:
- # 检查是否是结束标签 {tag}>
- 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
- # 部分匹配(可能跨 token)
- 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)
-
- async def process(
- self, response: AsyncIterable[bytes]
- ) -> AsyncGenerator[str, None]:
- """处理流式响应"""
- # 获取空闲超时配置
- idle_timeout = get_config("grok.stream_idle_timeout", 45.0)
-
- 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
-
- # 首次发送 role
- if not self.role_sent:
- yield self._sse(role="assistant")
- self.role_sent = True
-
- # 图像生成进度
- if img := resp.get("streamingImageGenerationResponse"):
- if self.show_think:
- if not self.think_opened:
- yield self._sse("\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("\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("\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:
- # HTTP/2 流错误转换为 UpstreamException
- 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):
- """非流式响应处理器"""
-
- # 需要过滤的标签
- FILTER_TAGS = ["grok:render", "xaiartifact", "xai:tool_usage_card"]
-
- def __init__(self, model: str, token: str = ""):
- super().__init__(model, token)
- self.image_format = get_config("app.image_format", "url")
- self.filter_tags = get_config("grok.filter_tags", self.FILTER_TAGS)
-
- def _filter_content(self, content: str) -> str:
- """过滤内容中的特殊标签"""
- import re
-
- if not content or not self.filter_tags:
- return content
-
- result = content
- for tag in self.filter_tags:
- # 匹配 ... 或 ,re.DOTALL 使 . 匹配换行符
- 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("grok.stream_idle_timeout", 45.0)
-
- 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,
- },
- },
- }
-
-
-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", "html")).lower()
-
- if think is None:
- self.show_think = get_config("grok.thinking", False)
- else:
- self.show_think = think
-
- def _build_video_html(self, video_url: str, thumbnail_url: str = "") -> str:
- """构建视频 HTML 标签"""
- 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''''''
-
- async def process(
- self, response: AsyncIterable[bytes]
- ) -> AsyncGenerator[str, None]:
- """处理视频流式响应"""
- # 视频生成使用更长的空闲超时
- idle_timeout = get_config("grok.video_idle_timeout", 90.0)
-
- 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
-
- # 首次发送 role
- if not self.role_sent:
- yield self._sse(role="assistant")
- self.role_sent = True
-
- # 视频生成进度
- if video_resp := resp.get("streamingVideoGenerationResponse"):
- progress = video_resp.get("progress", 0)
-
- if self.show_think:
- if not self.think_opened:
- yield self._sse("\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("\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("\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", "html")).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''''''
-
- async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]:
- """处理并收集视频响应"""
- response_id = ""
- content = ""
- # 视频生成使用更长的空闲超时
- idle_timeout = get_config("grok.video_idle_timeout", 90.0)
-
- 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},
- }
-
-
-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("grok.stream_idle_timeout", 45.0)
-
- 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("grok.stream_idle_timeout", 45.0)
-
- 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
-
-
-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:
- if self.app_url:
- return f"{self.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__ = [
- "StreamProcessor",
- "CollectProcessor",
- "VideoStreamProcessor",
- "VideoCollectProcessor",
- "ImageStreamProcessor",
- "ImageCollectProcessor",
- "ImageWSStreamProcessor",
- "ImageWSCollectProcessor",
-]
diff --git a/app/services/grok/processors/video_processors.py b/app/services/grok/processors/video_processors.py
new file mode 100644
index 0000000..a0ead8c
--- /dev/null
+++ b/app/services/grok/processors/video_processors.py
@@ -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''''''
+
+ 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("\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("\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("\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''''''
+
+ 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"]
diff --git a/app/services/grok/services/assets.py b/app/services/grok/services/assets.py
index 15791f4..d19ab4a 100644
--- a/app/services/grok/services/assets.py
+++ b/app/services/grok/services/assets.py
@@ -4,30 +4,30 @@ Grok 文件资产服务
import asyncio
import base64
-import os
-import time
import hashlib
+import os
import re
-from pathlib import Path
+import time
from contextlib import asynccontextmanager
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+from urllib.parse import urlparse
try:
import fcntl
-except ImportError: # pragma: no cover - non-posix platforms
+except ImportError:
fcntl = None
-from typing import Tuple, List, Dict, Optional, Any
-from urllib.parse import urlparse
import aiofiles
from curl_cffi.requests import AsyncSession
-from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import AppException, UpstreamException, ValidationException
+from app.core.logger import logger
from app.services.grok.utils.headers import apply_statsig, build_sso_cookie
from app.services.token.service import TokenService
-
# ==================== 常量 ====================
UPLOAD_API = "https://grok.com/rest/app-chat/upload-file"
@@ -36,53 +36,62 @@ DELETE_API = "https://grok.com/rest/assets-metadata"
DOWNLOAD_API = "https://assets.grok.com"
LOCK_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / ".locks"
-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"
-DEFAULT_MIME = "application/octet-stream"
+# 全局信号量(运行时动态初始化)
+_ASSETS_SEMAPHORE = None
+_ASSETS_SEM_VALUE = None
-# 并发控制
-DEFAULT_MAX_CONCURRENT = 25
-DEFAULT_DELETE_BATCH_SIZE = 10
-_ASSETS_SEMAPHORE = asyncio.Semaphore(DEFAULT_MAX_CONCURRENT)
-_ASSETS_SEM_VALUE = DEFAULT_MAX_CONCURRENT
+# 常用 MIME 类型(业务数据,非配置)
+MIME_TYPES = {
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".bmp": "image/bmp",
+ ".pdf": "application/pdf",
+ ".txt": "text/plain",
+ ".md": "text/markdown",
+ ".csv": "text/csv",
+ ".json": "application/json",
+ ".xml": "application/xml",
+ ".py": "text/x-python-script",
+ ".js": "application/javascript",
+ ".html": "text/html",
+ ".css": "text/css",
+ ".mp4": "video/mp4",
+ ".webm": "video/webm",
+}
+
+IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
+VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
+
+# ==================== 工具函数 ====================
def _get_assets_semaphore() -> asyncio.Semaphore:
+ """获取全局并发控制信号量"""
+ value = max(1, int(get_config("performance.assets_max_concurrent")))
+
global _ASSETS_SEMAPHORE, _ASSETS_SEM_VALUE
- value = get_config("performance.assets_max_concurrent", DEFAULT_MAX_CONCURRENT)
- try:
- value = int(value)
- except Exception:
- value = DEFAULT_MAX_CONCURRENT
- value = max(1, value)
- if value != _ASSETS_SEM_VALUE:
+ if _ASSETS_SEMAPHORE is None or value != _ASSETS_SEM_VALUE:
_ASSETS_SEM_VALUE = value
_ASSETS_SEMAPHORE = asyncio.Semaphore(value)
return _ASSETS_SEMAPHORE
-def _get_delete_batch_size() -> int:
- value = get_config(
- "performance.assets_delete_batch_size", DEFAULT_DELETE_BATCH_SIZE
- )
- try:
- value = int(value)
- except Exception:
- value = DEFAULT_DELETE_BATCH_SIZE
- return max(1, value)
-
-
@asynccontextmanager
async def _file_lock(name: str, timeout: int = 10):
+ """文件锁"""
if fcntl is None:
yield
return
+
LOCK_DIR.mkdir(parents=True, exist_ok=True)
lock_path = LOCK_DIR / f"{name}.lock"
fd = None
locked = False
start = time.monotonic()
+
try:
fd = open(lock_path, "a+")
while True:
@@ -102,65 +111,31 @@ async def _file_lock(name: str, timeout: int = 10):
fcntl.flock(fd, fcntl.LOCK_UN)
except Exception:
pass
- try:
- fd.close()
- except Exception:
- pass
+ fd.close()
-MIME_TYPES = {
- # 图片
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".png": "image/png",
- ".gif": "image/gif",
- ".webp": "image/webp",
- ".bmp": "image/bmp",
- # 文档
- ".pdf": "application/pdf",
- ".txt": "text/plain",
- ".md": "text/markdown",
- ".doc": "application/msword",
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- ".rtf": "application/rtf",
- # 表格
- ".csv": "text/csv",
- ".xls": "application/vnd.ms-excel",
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- # 代码
- ".py": "text/x-python-script",
- ".js": "application/javascript",
- ".ts": "application/typescript",
- ".java": "text/x-java",
- ".cpp": "text/x-c++",
- ".c": "text/x-c",
- ".go": "text/x-go",
- ".rs": "text/x-rust",
- ".rb": "text/x-ruby",
- ".php": "text/x-php",
- ".sh": "application/x-sh",
- ".html": "text/html",
- ".css": "text/css",
- ".sql": "application/sql",
- # 数据
- ".json": "application/json",
- ".xml": "application/xml",
- ".yaml": "application/x-yaml",
- ".yml": "application/x-yaml",
- ".toml": "application/toml",
- ".ini": "text/plain",
- ".log": "text/plain",
- ".tmp": "application/octet-stream",
- # 其他
- ".graphql": "application/graphql",
- ".proto": "application/x-protobuf",
- ".latex": "application/x-latex",
- ".wiki": "text/plain",
- ".rst": "text/x-rst",
-}
+@dataclass
+class ServiceConfig:
+ """服务配置"""
-IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
-VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
+ proxy: str
+ timeout: int
+ browser: str
+ user_agent: str
+
+ @classmethod
+ def from_settings(cls, proxy: Optional[str] = None):
+ return cls(
+ proxy=proxy
+ or get_config("network.asset_proxy_url")
+ or get_config("network.base_proxy_url"),
+ timeout=get_config("network.timeout"),
+ browser=get_config("security.browser"),
+ user_agent=get_config("security.user_agent"),
+ )
+
+ def get_proxies(self) -> Optional[dict]:
+ return {"http": self.proxy, "https": self.proxy} if self.proxy else None
# ==================== 基础服务 ====================
@@ -169,64 +144,32 @@ VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
class BaseService:
"""基础服务类"""
- def __init__(self, proxy: str = None):
- self.proxy = (
- proxy
- or get_config("grok.asset_proxy_url")
- or get_config("grok.base_proxy_url", "")
- )
- self.timeout = get_config("grok.timeout", TIMEOUT)
+ def __init__(self, proxy: Optional[str] = None):
+ self.config = ServiceConfig.from_settings(proxy)
self._session: Optional[AsyncSession] = None
- def _headers(self, token: str, referer: str = "https://grok.com/") -> dict:
+ def _build_headers(
+ self, token: str, referer: str = "https://grok.com/", download: bool = False
+ ) -> dict:
"""构建请求头"""
- user_agent = get_config("grok.user_agent", DEFAULT_USER_AGENT)
- headers = {
- "Accept": "*/*",
- "Accept-Encoding": "gzip, deflate, br, zstd",
- "Accept-Language": "zh-CN,zh;q=0.9",
- "Baggage": "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c",
- "Cache-Control": "no-cache",
- "Content-Type": "application/json",
- "Origin": "https://grok.com",
- "Pragma": "no-cache",
- "Priority": "u=1, i",
- "Referer": referer,
- "Sec-Ch-Ua": '"Google Chrome";v="136", "Chromium";v="136", "Not(A:Brand";v="24"',
- "Sec-Ch-Ua-Arch": "arm",
- "Sec-Ch-Ua-Bitness": "64",
- "Sec-Ch-Ua-Mobile": "?0",
- "Sec-Ch-Ua-Model": "",
- "Sec-Ch-Ua-Platform": '"macOS"',
- "Sec-Fetch-Dest": "empty",
- "Sec-Fetch-Mode": "cors",
- "Sec-Fetch-Site": "same-origin",
- "User-Agent": user_agent,
- }
-
- apply_statsig(headers)
- headers["Cookie"] = build_sso_cookie(token)
-
- return headers
-
- def _proxies(self) -> Optional[dict]:
- """构建代理配置"""
- return {"http": self.proxy, "https": self.proxy} if self.proxy else None
-
- def _dl_headers(self, token: str, file_path: str) -> dict:
- """构建下载请求头"""
- headers = {
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
- "Sec-Fetch-Dest": "document",
- "Sec-Fetch-Mode": "navigate",
- "Sec-Fetch-Site": "same-site",
- "Sec-Fetch-User": "?1",
- "Upgrade-Insecure-Requests": "1",
- "Referer": "https://grok.com/",
- }
+ if download:
+ headers = {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Referer": referer,
+ }
+ else:
+ headers = {
+ "Accept": "*/*",
+ "Content-Type": "application/json",
+ "Origin": "https://grok.com",
+ "Referer": referer,
+ "User-Agent": self.config.user_agent,
+ }
+ apply_statsig(headers)
headers["Cookie"] = build_sso_cookie(token)
-
return headers
async def _get_session(self) -> AsyncSession:
@@ -242,68 +185,62 @@ class BaseService:
self._session = None
@staticmethod
- def is_url(input_str: str) -> bool:
+ def is_url(s: str) -> bool:
"""检查是否为 URL"""
try:
- result = urlparse(input_str)
- return all([result.scheme, result.netloc]) and result.scheme in [
- "http",
- "https",
- ]
+ r = urlparse(s)
+ return bool(r.scheme and r.netloc and r.scheme in ["http", "https"])
except Exception:
return False
@staticmethod
async def fetch(url: str) -> Tuple[str, str, str]:
- """
- 获取远程资源并转 Base64
-
- Raises:
- UpstreamException: 当获取失败时
- """
+ """获取远程资源并转 Base64"""
try:
async with AsyncSession() as session:
response = await session.get(url, timeout=10)
if response.status_code >= 400:
raise UpstreamException(
- message=f"Failed to fetch resource: {response.status_code}",
+ message=f"Failed to fetch: {response.status_code}",
details={"url": url, "status": response.status_code},
)
filename = url.split("/")[-1].split("?")[0] or "download"
- content_type = response.headers.get("content-type", DEFAULT_MIME).split(
- ";"
- )[0]
+ content_type = response.headers.get(
+ "content-type", "application/octet-stream"
+ ).split(";")[0]
b64 = base64.b64encode(response.content).decode()
- logger.debug(f"Fetched: {url} -> {filename}")
+ logger.debug(f"Fetched: {url}")
return filename, b64, content_type
except Exception as e:
- logger.error(f"Fetch failed: {url} - {e}")
if isinstance(e, AppException):
- raise e
- raise UpstreamException(
- f"Resource fetch failed: {str(e)}", details={"url": url}
- )
+ raise
+ logger.error(f"Fetch failed: {url} - {e}")
+ raise UpstreamException(f"Fetch failed: {str(e)}", details={"url": url})
@staticmethod
def parse_b64(data_uri: str) -> Tuple[str, str, str]:
"""解析 Base64 数据"""
- if data_uri.startswith("data:"):
- try:
- header, b64 = data_uri.split(",", 1)
- except ValueError:
- return "file.bin", data_uri, DEFAULT_MIME
- if ";base64" in header:
- mime = header[5:].split(";", 1)[0] or DEFAULT_MIME
- b64 = re.sub(r"\\s+", "", b64)
- ext = mime.split("/")[-1] if "/" in mime else "bin"
- return f"file.{ext}", b64, mime
- return "file.bin", data_uri, DEFAULT_MIME
+ if not data_uri.startswith("data:"):
+ return "file.bin", data_uri, "application/octet-stream"
+
+ try:
+ header, b64 = data_uri.split(",", 1)
+ except ValueError:
+ return "file.bin", data_uri, "application/octet-stream"
+
+ if ";base64" not in header:
+ return "file.bin", data_uri, "application/octet-stream"
+
+ mime = header[5:].split(";", 1)[0] or "application/octet-stream"
+ b64 = re.sub(r"\s+", "", b64)
+ ext = mime.split("/")[-1] if "/" in mime else "bin"
+ return f"file.{ext}", b64, mime
@staticmethod
def to_b64(file_path: Path, mime_type: str) -> str:
- """将本地文件转为 base64 data URI"""
+ """文件转 base64 data URI"""
try:
b64_data = base64.b64encode(file_path.read_bytes()).decode()
return f"data:{mime_type};base64,{b64_data}"
@@ -326,92 +263,61 @@ class UploadService(BaseService):
Returns:
(file_id, file_uri)
-
- Raises:
- ValidationException: 输入无效
- UpstreamException: 上传失败
"""
async with _get_assets_semaphore():
- try:
- # 处理输入
- if self.is_url(file_input):
- filename, b64, mime = await self.fetch(file_input)
- else:
- filename, b64, mime = self.parse_b64(file_input)
+ # 处理输入
+ if self.is_url(file_input):
+ filename, b64, mime = await self.fetch(file_input)
+ else:
+ filename, b64, mime = self.parse_b64(file_input)
- if not b64:
- raise ValidationException("Invalid file input: empty content")
+ logger.debug(
+ f"Upload prepare: filename={filename}, type={mime}, size={len(b64)}"
+ )
- # 构建请求
- headers = self._headers(token)
- payload = {
- "fileName": filename,
- "fileMimeType": mime,
- "content": b64,
- }
+ if not b64:
+ raise ValidationException("Invalid file input: empty content")
- # 执行上传
- session = await self._get_session()
- response = await session.post(
- UPLOAD_API,
- headers=headers,
- json=payload,
- impersonate=get_config("grok.browser", DEFAULT_BROWSER),
- timeout=self.timeout,
- proxies=self._proxies(),
- )
+ # 执行上传
+ session = await self._get_session()
+ response = await session.post(
+ UPLOAD_API,
+ headers=self._build_headers(token),
+ json={"fileName": filename, "fileMimeType": mime, "content": b64},
+ impersonate=self.config.browser,
+ timeout=self.config.timeout,
+ proxies=self.config.get_proxies(),
+ )
- if response.status_code == 200:
- result = response.json()
- file_id = result.get("fileMetadataId", "")
- file_uri = result.get("fileUri", "")
- logger.info(
- f"Upload success: {filename} -> {file_id}",
- extra={"file_id": file_id},
+ # 处理响应
+ if response.status_code == 200:
+ result = response.json()
+ file_id = result.get("fileMetadataId", "")
+ file_uri = result.get("fileUri", "")
+ logger.info(f"Upload success: {filename} -> {file_id}")
+ return file_id, file_uri
+
+ # 认证失败
+ if response.status_code in (401, 403):
+ logger.warning(f"Upload auth failed: {response.status_code}")
+ try:
+ await TokenService.record_fail(
+ token, response.status_code, "upload_auth_failed"
)
- return file_id, file_uri
+ except Exception as e:
+ logger.error(f"Failed to record token failure: {e}")
- # 401/403 表示 token 失效,记录失败并标记冷却
- if response.status_code in (401, 403):
- logger.warning(
- f"Upload token invalid: {response.status_code}, recording failure",
- extra={"response": response.text[:200]},
- )
- try:
- await TokenService.record_fail(
- token,
- status_code=response.status_code,
- reason="upload_auth_failed",
- )
- except Exception as mark_err:
- logger.error(f"Failed to record token failure: {mark_err}")
-
- raise UpstreamException(
- message=f"Upload authentication failed: {response.status_code}",
- details={
- "status": response.status_code,
- "response": response.text[:200],
- "token_invalidated": True,
- },
- )
-
- logger.error(
- f"Upload failed: {filename} - {response.status_code}",
- extra={"response": response.text[:200]},
- )
raise UpstreamException(
- message=f"Upload failed with status {response.status_code}",
- details={
- "status": response.status_code,
- "response": response.text[:200],
- },
+ message=f"Upload authentication failed: {response.status_code}",
+ details={"status": response.status_code, "token_invalidated": True},
)
- except Exception as e:
- logger.error(f"Upload error: {e}")
- if isinstance(e, AppException):
- raise e
- raise UpstreamException(f"Upload process error: {str(e)}")
+ # 其他错误
+ logger.error(f"Upload failed: {filename} - {response.status_code}")
+ raise UpstreamException(
+ message=f"Upload failed: {response.status_code}",
+ details={"status": response.status_code},
+ )
# ==================== 列表服务 ====================
@@ -421,11 +327,9 @@ class ListService(BaseService):
"""文件列表查询服务"""
async def iter_assets(self, token: str):
- """
- 分页迭代资产列表
- """
- headers = self._headers(token, referer="https://grok.com/files")
- base_params = {
+ """分页迭代资产列表"""
+ headers = self._build_headers(token, referer="https://grok.com/files")
+ params = {
"pageSize": 50,
"orderBy": "ORDER_BY_LAST_USE_TIME",
"source": "SOURCE_ANY",
@@ -436,29 +340,27 @@ class ListService(BaseService):
async with AsyncSession() as session:
while True:
- params = dict(base_params)
if page_token:
if page_token in seen_tokens:
- logger.warning(
- "List pagination stopped due to repeated page token"
- )
+ logger.warning("Pagination stopped: repeated page token")
break
seen_tokens.add(page_token)
params["pageToken"] = page_token
+ else:
+ params.pop("pageToken", None)
response = await session.get(
LIST_API,
headers=headers,
params=params,
- impersonate=get_config("grok.browser", DEFAULT_BROWSER),
- timeout=self.timeout,
- proxies=self._proxies(),
+ impersonate=self.config.browser,
+ timeout=self.config.timeout,
+ proxies=self.config.get_proxies(),
)
if response.status_code != 200:
- logger.error(f"List failed: {response.status_code}")
raise UpstreamException(
- message=f"List assets failed: {response.status_code}",
+ message=f"List failed: {response.status_code}",
details={"status": response.status_code},
)
@@ -471,40 +373,20 @@ class ListService(BaseService):
break
async def list(self, token: str) -> List[Dict]:
- """
- 查询文件列表
-
- Raises:
- UpstreamException: 查询失败
- """
- try:
- assets: List[Dict] = []
- async for page_assets in self.iter_assets(token):
- assets.extend(page_assets)
-
- logger.info(f"List success: {len(assets)} files")
- return assets
-
- except Exception as e:
- logger.error(f"List error: {e}")
- if isinstance(e, AppException):
- raise e
- raise UpstreamException(f"List assets error: {str(e)}")
+ """查询文件列表"""
+ assets = []
+ async for page_assets in self.iter_assets(token):
+ assets.extend(page_assets)
+ logger.info(f"List success: {len(assets)} files")
+ return assets
async def count(self, token: str) -> int:
- """
- 统计资产数量(不保留明细)
- """
- try:
- total = 0
- async for page_assets in self.iter_assets(token):
- total += len(page_assets)
- return total
- except Exception as e:
- logger.error(f"List count error: {e}")
- if isinstance(e, AppException):
- raise e
- raise UpstreamException(f"List assets error: {str(e)}")
+ """统计资产数量"""
+ total = 0
+ async for page_assets in self.iter_assets(token):
+ total += len(page_assets)
+ logger.debug(f"Asset count: {total}")
+ return total
# ==================== 删除服务 ====================
@@ -514,91 +396,81 @@ class DeleteService(BaseService):
"""文件删除服务"""
async def delete(self, token: str, asset_id: str) -> bool:
- """
- 删除单个文件
-
- Raises:
- UpstreamException: 删除失败
- """
+ """删除单个文件"""
async with _get_assets_semaphore():
- try:
- headers = self._headers(token, referer="https://grok.com/files")
- url = f"{DELETE_API}/{asset_id}"
+ session = await self._get_session()
+ response = await session.delete(
+ f"{DELETE_API}/{asset_id}",
+ headers=self._build_headers(token, referer="https://grok.com/files"),
+ impersonate=self.config.browser,
+ timeout=self.config.timeout,
+ proxies=self.config.get_proxies(),
+ )
- session = await self._get_session()
- response = await session.delete(
- url,
- headers=headers,
- impersonate=get_config("grok.browser", DEFAULT_BROWSER),
- timeout=self.timeout,
- proxies=self._proxies(),
- )
+ if response.status_code == 200:
+ logger.debug(f"Deleted: {asset_id}")
+ return True
- if response.status_code == 200:
- logger.debug(f"Delete success: {asset_id}")
- return True
-
- logger.error(f"Delete failed: {asset_id} - {response.status_code}")
- #: Note: Returning False or raising Exception?
- #: Assuming caller handles Exception for stricter control, or False for loose control.
- #: Given "optimization" and "standardization", raising exceptions is better for API feedback.
- raise UpstreamException(
- message=f"Delete failed: {asset_id}",
- details={"status": response.status_code},
- )
-
- except Exception as e:
- logger.error(f"Delete error: {asset_id} - {e}")
- if isinstance(e, AppException):
- raise e
- raise UpstreamException(f"Delete error: {str(e)}")
+ logger.error(f"Delete failed: {asset_id} - {response.status_code}")
+ raise UpstreamException(
+ message=f"Delete failed: {asset_id}",
+ details={"status": response.status_code},
+ )
async def delete_all(self, token: str) -> Dict[str, int]:
- """
- 删除所有文件
- """
- total = 0
- success = 0
- failed = 0
- list_service = ListService(self.proxy)
+ """删除所有文件"""
+ total = success = failed = 0
+ list_service = ListService(self.config.proxy)
+
try:
async for assets in list_service.iter_assets(token):
if not assets:
continue
+
total += len(assets)
-
- # 批量并发删除
- async def _delete_one(asset: Dict, index: int) -> bool:
- await asyncio.sleep(0.01 * index)
- asset_id = asset.get("assetId", "")
- if asset_id:
- try:
- return await self.delete(token, asset_id)
- except Exception:
- return False
- return False
-
- batch_size = _get_delete_batch_size()
- for i in range(0, len(assets), batch_size):
- batch = assets[i : i + batch_size]
- results = await asyncio.gather(
- *[_delete_one(asset, idx) for idx, asset in enumerate(batch)]
- )
- success += sum(results)
- failed += len(batch) - sum(results)
+ batch_result = await self._delete_batch(token, assets)
+ success += batch_result["success"]
+ failed += batch_result["failed"]
if total == 0:
logger.info("No assets to delete")
return {"total": 0, "success": 0, "failed": 0, "skipped": True}
- except Exception as e:
- logger.error(f"Delete all failed during list: {e}")
- return {"total": total, "success": success, "failed": failed}
finally:
await list_service.close()
logger.info(f"Delete all: total={total}, success={success}, failed={failed}")
return {"total": total, "success": success, "failed": failed}
+ async def _delete_batch(self, token: str, assets: List[Dict]) -> Dict[str, int]:
+ """批量删除"""
+ batch_size = max(1, int(get_config("performance.assets_delete_batch_size")))
+ success = failed = 0
+
+ for i in range(0, len(assets), batch_size):
+ batch = assets[i : i + batch_size]
+ results = await asyncio.gather(
+ *[
+ self._delete_one(token, asset, idx)
+ for idx, asset in enumerate(batch)
+ ],
+ return_exceptions=True,
+ )
+ success += sum(1 for r in results if r is True)
+ failed += sum(1 for r in results if r is not True)
+
+ return {"success": success, "failed": failed}
+
+ async def _delete_one(self, token: str, asset: Dict, index: int) -> bool:
+ """删除单个资产(带延迟)"""
+ await asyncio.sleep(0.01 * index)
+ asset_id = asset.get("assetId", "")
+ if not asset_id:
+ return False
+ try:
+ return await self.delete(token, asset_id)
+ except Exception:
+ return False
+
# ==================== 下载服务 ====================
@@ -606,10 +478,11 @@ class DeleteService(BaseService):
class DownloadService(BaseService):
"""文件下载服务"""
- def __init__(self, proxy: str = None):
+ def __init__(self, proxy: Optional[str] = None):
super().__init__(proxy)
- # 创建缓存目录
- self.base_dir = Path(__file__).parent.parent.parent.parent.parent / "data" / "tmp"
+ self.base_dir = (
+ Path(__file__).parent.parent.parent.parent.parent / "data" / "tmp"
+ )
self.image_dir = self.base_dir / "image"
self.video_dir = self.base_dir / "video"
self.image_dir.mkdir(parents=True, exist_ok=True)
@@ -622,130 +495,106 @@ class DownloadService(BaseService):
filename = file_path.lstrip("/").replace("/", "-")
return cache_dir / filename
+ def _get_mime(self, cache_path: Path, response=None) -> str:
+ """获取 MIME 类型"""
+ if response:
+ return response.headers.get(
+ "content-type", "application/octet-stream"
+ ).split(";")[0]
+ return MIME_TYPES.get(cache_path.suffix.lower(), "application/octet-stream")
+
async def download(
self, file_path: str, token: str, media_type: str = "image"
) -> Tuple[Optional[Path], str]:
- """
- 下载文件到本地
-
- Raises:
- UpstreamException: 下载失败
- """
+ """下载文件到本地"""
async with _get_assets_semaphore():
- try:
- cache_path = self._cache_path(file_path, media_type)
+ cache_path = self._cache_path(file_path, media_type)
- # 如果已缓存
+ # 检查缓存
+ if cache_path.exists():
+ logger.debug(f"Cache hit: {cache_path}")
+ return cache_path, self._get_mime(cache_path)
+
+ # 文件锁防止并发下载
+ lock_name = f"dl_{media_type}_{hashlib.sha1(str(cache_path).encode()).hexdigest()[:16]}"
+ async with _file_lock(lock_name, timeout=10):
+ # 双重检查
if cache_path.exists():
- logger.debug(f"Cache hit: {cache_path}")
- mime_type = MIME_TYPES.get(cache_path.suffix.lower(), DEFAULT_MIME)
- return cache_path, mime_type
+ return cache_path, self._get_mime(cache_path)
- lock_name = f"download_{media_type}_{hashlib.sha1(str(cache_path).encode('utf-8')).hexdigest()[:16]}"
- async with _file_lock(lock_name, timeout=10):
- # Double-check after lock
- if cache_path.exists():
- logger.debug(f"Cache hit after lock: {cache_path}")
- mime_type = MIME_TYPES.get(
- cache_path.suffix.lower(), DEFAULT_MIME
- )
- return cache_path, mime_type
+ # 执行下载
+ mime = await self._download_file(file_path, token, cache_path)
+ logger.info(f"Downloaded: {file_path}")
- # 下载文件
- if not file_path.startswith("/"):
- file_path = f"/{file_path}"
+ # 异步检查缓存限制
+ asyncio.create_task(self.check_limit())
- url = f"{DOWNLOAD_API}{file_path}"
- headers = self._dl_headers(token, file_path)
+ return cache_path, mime
- session = await self._get_session()
- response = await session.get(
- url,
- headers=headers,
- proxies=self._proxies(),
- timeout=self.timeout,
- allow_redirects=True,
- impersonate=get_config("grok.browser", DEFAULT_BROWSER),
- stream=True,
- )
+ async def _download_file(self, file_path: str, token: str, cache_path: Path) -> str:
+ """执行下载"""
+ if not file_path.startswith("/"):
+ file_path = f"/{file_path}"
- if response.status_code != 200:
- raise UpstreamException(
- message=f"Download failed: {response.status_code}",
- details={"path": file_path, "status": response.status_code},
- )
+ url = f"{DOWNLOAD_API}{file_path}"
+ headers = self._build_headers(token, download=True)
- # 保存文件(分块写入,避免大文件占用内存)
- tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
- try:
- async with aiofiles.open(tmp_path, "wb") as f:
- if hasattr(response, "aiter_content"):
- async for chunk in response.aiter_content():
- if chunk:
- await f.write(chunk)
- elif hasattr(response, "aiter_bytes"):
- async for chunk in response.aiter_bytes():
- if chunk:
- await f.write(chunk)
- elif hasattr(response, "aiter_raw"):
- async for chunk in response.aiter_raw():
- if chunk:
- await f.write(chunk)
- else:
- await f.write(response.content)
- os.replace(tmp_path, cache_path)
- finally:
- if tmp_path.exists() and not cache_path.exists():
- try:
- tmp_path.unlink()
- except Exception:
- pass
- mime_type = response.headers.get(
- "content-type", DEFAULT_MIME
- ).split(";")[0]
+ session = await self._get_session()
+ response = await session.get(
+ url,
+ headers=headers,
+ proxies=self.config.get_proxies(),
+ timeout=self.config.timeout,
+ allow_redirects=True,
+ impersonate=self.config.browser,
+ stream=True,
+ )
- logger.info(f"Download success: {file_path}")
+ if response.status_code != 200:
+ raise UpstreamException(
+ message=f"Download failed: {response.status_code}",
+ details={"path": file_path, "status": response.status_code},
+ )
- # 检查缓存限制
- asyncio.create_task(self.check_limit())
+ # 保存文件
+ tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
+ try:
+ async with aiofiles.open(tmp_path, "wb") as f:
+ # 尝试流式写入
+ if hasattr(response, "aiter_content"):
+ async for chunk in response.aiter_content():
+ if chunk:
+ await f.write(chunk)
+ else:
+ await f.write(response.content)
+ os.replace(tmp_path, cache_path)
+ finally:
+ if tmp_path.exists() and not cache_path.exists():
+ try:
+ tmp_path.unlink()
+ except Exception:
+ pass
- return cache_path, mime_type
-
- except Exception as e:
- logger.error(f"Download failed: {file_path} - {e}")
- if isinstance(e, AppException):
- raise e
- raise UpstreamException(f"Download error: {str(e)}")
+ return self._get_mime(cache_path, response)
async def to_base64(
self, file_path: str, token: str, media_type: str = "image"
) -> str:
- """
- 下载文件并转为 base64
- """
- try:
- cache_path, mime_type = await self.download(file_path, token, media_type)
+ """下载并转 base64"""
+ cache_path, mime = await self.download(file_path, token, media_type)
+ if not cache_path or not cache_path.exists():
+ raise AppException("Download failed: invalid path")
- if not cache_path or not cache_path.exists():
- raise AppException("File download returned invalid path")
+ data_uri = self.to_b64(cache_path, mime)
- # 使用基础服务的工具方法转换
- data_uri = self.to_b64(cache_path, mime_type)
+ # 删除临时文件
+ if data_uri:
+ try:
+ cache_path.unlink()
+ except Exception:
+ pass
- # 删除临时文件
- if data_uri:
- try:
- cache_path.unlink()
- except Exception as e:
- logger.warning(f"Delete temp file failed: {e}")
-
- return data_uri
-
- except Exception as e:
- logger.error(f"To base64 failed: {file_path} - {e}")
- if isinstance(e, AppException):
- raise e
- raise AppException(f"Base64 conversion failed: {str(e)}")
+ return data_uri
def get_stats(self, media_type: str = "image") -> Dict[str, Any]:
"""获取缓存统计"""
@@ -753,24 +602,31 @@ class DownloadService(BaseService):
if not cache_dir.exists():
return {"count": 0, "size_mb": 0.0}
- files = [f for f in cache_dir.glob("*") if f.is_file()]
- allowed_exts = IMAGE_EXTS if media_type == "image" else VIDEO_EXTS
- files = [f for f in files if f.suffix.lower() in allowed_exts]
+ allowed = IMAGE_EXTS if media_type == "image" else VIDEO_EXTS
+ files = [
+ f
+ for f in cache_dir.glob("*")
+ if f.is_file() and f.suffix.lower() in allowed
+ ]
total_size = sum(f.stat().st_size for f in files)
-
return {"count": len(files), "size_mb": round(total_size / 1024 / 1024, 2)}
def list_files(
self, media_type: str = "image", page: int = 1, page_size: int = 1000
) -> Dict[str, Any]:
- """列出本地缓存文件"""
+ """列出缓存文件"""
cache_dir = self.image_dir if media_type == "image" else self.video_dir
if not cache_dir.exists():
return {"total": 0, "page": page, "page_size": page_size, "items": []}
- files = [f for f in cache_dir.glob("*") if f.is_file()]
- allowed_exts = IMAGE_EXTS if media_type == "image" else VIDEO_EXTS
- files = [f for f in files if f.suffix.lower() in allowed_exts]
+ allowed = IMAGE_EXTS if media_type == "image" else VIDEO_EXTS
+ files = [
+ f
+ for f in cache_dir.glob("*")
+ if f.is_file() and f.suffix.lower() in allowed
+ ]
+
+ # 构建文件列表
items = []
for f in files:
try:
@@ -786,40 +642,30 @@ class DownloadService(BaseService):
continue
items.sort(key=lambda x: x["mtime_ms"], reverse=True)
+
+ # 分页
total = len(items)
start = max(0, (page - 1) * page_size)
- end = start + page_size
- paged = items[start:end]
+ paged = items[start : start + page_size]
- if media_type == "image":
- for item in paged:
- item["view_url"] = f"/v1/files/image/{item['name']}"
- else:
- preview_map = {}
- if self.image_dir.exists():
- for img in self.image_dir.glob("*"):
- if img.is_file() and img.suffix.lower() in IMAGE_EXTS:
- preview_map.setdefault(img.stem, img.name)
- for item in paged:
- item["view_url"] = f"/v1/files/video/{item['name']}"
- preview_name = preview_map.get(Path(item["name"]).stem)
- if preview_name:
- item["preview_url"] = f"/v1/files/image/{preview_name}"
+ # 添加 URL
+ for item in paged:
+ item["view_url"] = f"/v1/files/{media_type}/{item['name']}"
return {"total": total, "page": page, "page_size": page_size, "items": paged}
def delete_file(self, media_type: str, name: str) -> Dict[str, Any]:
- """删除单个缓存文件"""
+ """删除缓存文件"""
cache_dir = self.image_dir if media_type == "image" else self.video_dir
- safe_name = name.replace("/", "-")
- file_path = cache_dir / safe_name
- if not file_path.exists():
- return {"deleted": False}
- try:
- file_path.unlink()
- return {"deleted": True}
- except Exception:
- return {"deleted": False}
+ file_path = cache_dir / name.replace("/", "-")
+
+ if file_path.exists():
+ try:
+ file_path.unlink()
+ return {"deleted": True}
+ except Exception:
+ pass
+ return {"deleted": False}
def clear(self, media_type: str = "image") -> Dict[str, Any]:
"""清空缓存"""
@@ -828,59 +674,43 @@ class DownloadService(BaseService):
return {"count": 0, "size_mb": 0.0}
files = list(cache_dir.glob("*"))
- total_size = sum(f.stat().st_size for f in files)
+ total_size = sum(f.stat().st_size for f in files if f.is_file())
count = 0
for f in files:
- try:
- f.unlink()
- count += 1
- except Exception as e:
- logger.error(f"Failed to delete {f}: {e}")
+ if f.is_file():
+ try:
+ f.unlink()
+ count += 1
+ except Exception:
+ pass
return {"count": count, "size_mb": round(total_size / 1024 / 1024, 2)}
async def check_limit(self):
- """检查并清理缓存限制"""
- if self._cleanup_running:
+ """检查并清理缓存"""
+ if self._cleanup_running or not get_config("cache.enable_auto_clean"):
return
+
self._cleanup_running = True
try:
async with _file_lock("cache_cleanup", timeout=5):
- if not get_config("cache.enable_auto_clean", True):
- return
-
- limit_mb = get_config("cache.limit_mb", 1024)
-
- # 统计总大小
- total_size = 0
- all_files = []
-
- for d in [self.image_dir, self.video_dir]:
- if d.exists():
- for f in d.glob("*"):
- try:
- stat = f.stat()
- total_size += stat.st_size
- all_files.append((f, stat.st_mtime, stat.st_size))
- except Exception:
- pass
-
+ limit_mb = get_config("cache.limit_mb")
+ all_files, total_size = self._collect_files()
current_mb = total_size / 1024 / 1024
+
if current_mb <= limit_mb:
return
- # 需要清理
+ # 清理到 80%
logger.info(
- f"Cache limit exceeded ({current_mb:.2f}MB > {limit_mb}MB), cleaning up..."
+ f"Cache limit exceeded ({current_mb:.2f}MB > {limit_mb}MB), cleaning..."
)
-
- # 按时间排序
- all_files.sort(key=lambda x: x[1])
+ all_files.sort(key=lambda x: x[1]) # 按时间排序
deleted_count = 0
deleted_size = 0
- target_mb = limit_mb * 0.8 # 清理到 80%
+ target_mb = limit_mb * 0.8
for f, _, size in all_files:
try:
@@ -888,33 +718,34 @@ class DownloadService(BaseService):
deleted_count += 1
deleted_size += size
total_size -= size
-
if (total_size / 1024 / 1024) <= target_mb:
break
- except Exception as e:
- logger.error(f"Cleanup failed for {f}: {e}")
+ except Exception:
+ pass
logger.info(
- f"Cache cleanup: deleted {deleted_count} files ({deleted_size / 1024 / 1024:.2f}MB)"
+ f"Cache cleanup: {deleted_count} files ({deleted_size / 1024 / 1024:.2f}MB)"
)
finally:
self._cleanup_running = False
- def get_public_url(self, file_path: str) -> str:
- """
- 获取文件的公共访问 URL
+ def _collect_files(self) -> Tuple[List[Tuple[Path, float, int]], int]:
+ """收集所有缓存文件"""
+ total_size = 0
+ all_files = []
- 如果配置了 app_url,则返回自托管 URL,否则返回 Grok 原始 URL
- """
- app_url = get_config("app.app_url", "")
- if not app_url:
- return f"{DOWNLOAD_API}{file_path if file_path.startswith('/') else '/' + file_path}"
+ for d in [self.image_dir, self.video_dir]:
+ if d.exists():
+ for f in d.glob("*"):
+ if f.is_file():
+ try:
+ stat = f.stat()
+ total_size += stat.st_size
+ all_files.append((f, stat.st_mtime, stat.st_size))
+ except Exception:
+ pass
- if not file_path.startswith("/"):
- file_path = f"/{file_path}"
-
- # 自动添加 /v1/files 前缀
- return f"{app_url.rstrip('/')}/v1/files{file_path}"
+ return all_files, total_size
__all__ = [
diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py
index 237cd38..dcd36cc 100644
--- a/app/services/grok/services/chat.py
+++ b/app/services/grok/services/chat.py
@@ -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 ""
- 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__ = [
diff --git a/app/services/grok/services/image.py b/app/services/grok/services/image.py
index 8d0ebbb..089a5f0 100644
--- a/app/services/grok/services/image.py
+++ b/app/services/grok/services/image.py
@@ -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"]
diff --git a/app/services/grok/services/media.py b/app/services/grok/services/media.py
index 7b0eab7..e3f4917 100644
--- a/app/services/grok/services/media.py
+++ b/app/services/grok/services/media.py
@@ -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"]
diff --git a/app/services/grok/services/nsfw.py b/app/services/grok/services/nsfw.py
index be797a4..26a4f26 100644
--- a/app/services/grok/services/nsfw.py
+++ b/app/services/grok/services/nsfw.py
@@ -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 都算成功
diff --git a/app/services/grok/services/usage.py b/app/services/grok/services/usage.py
index a339962..7550c82 100644
--- a/app/services/grok/services/usage.py
+++ b/app/services/grok/services/usage.py
@@ -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}",
diff --git a/app/services/grok/services/voice.py b/app/services/grok/services/voice.py
index 8b87991..006fc54 100644
--- a/app/services/grok/services/voice.py
+++ b/app/services/grok/services/voice.py
@@ -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"},
}
diff --git a/app/services/grok/utils/headers.py b/app/services/grok/utils/headers.py
index 71dc0e5..7a5e1c2 100644
--- a/app/services/grok/utils/headers.py
+++ b/app/services/grok/utils/headers.py
@@ -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}"
diff --git a/app/services/grok/utils/retry.py b/app/services/grok/utils/retry.py
index 69299da..162c4f8 100644
--- a/app/services/grok/utils/retry.py
+++ b/app/services/grok/utils/retry.py
@@ -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",
diff --git a/app/services/grok/utils/statsig.py b/app/services/grok/utils/statsig.py
index 7dd1b80..c2cd15f 100644
--- a/app/services/grok/utils/statsig.py
+++ b/app/services/grok/utils/statsig.py
@@ -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="
diff --git a/app/services/grok/utils/stream.py b/app/services/grok/utils/stream.py
new file mode 100644
index 0000000..c9e64dd
--- /dev/null
+++ b/app/services/grok/utils/stream.py
@@ -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"]
diff --git a/app/services/token/manager.py b/app/services/token/manager.py
index c7f3da7..8ff5e74 100644
--- a/app/services/token/manager.py
+++ b/app/services/token/manager.py
@@ -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()
diff --git a/app/static/config/config.html b/app/static/config/config.html
index 7fb207f..6fdff9e 100644
--- a/app/static/config/config.html
+++ b/app/static/config/config.html
@@ -50,7 +50,7 @@
-
+