Files

306 lines
11 KiB
Python

"""
Reverse interface: app chat conversations.
"""
import orjson
import inspect
from typing import Any, Dict, List, Optional
from curl_cffi.requests import AsyncSession
from curl_cffi.requests.errors import RequestsError
from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import UpstreamException
from app.services.token.service import TokenService
from app.services.reverse.utils.headers import build_headers
from app.services.reverse.utils.retry import retry_on_status
CHAT_API = "https://grok.com/rest/app-chat/conversations/new"
def _is_transient_network_error(err: Exception) -> bool:
"""判断是否为可快速重试的弱网/连接类错误。"""
s = str(err or "").lower()
keywords = (
"curl: (28)", # operation timeout
"curl: (35)", # tls connect error
"tls connect error",
"ssl connect error",
"failed to connect",
"couldn't connect",
"connection timed out",
"connection reset",
"network is unreachable",
"timed out",
)
return any(k in s for k in keywords)
class AppChatReverse:
"""/rest/app-chat/conversations/new reverse interface."""
@staticmethod
def _resolve_custom_personality() -> Optional[str]:
"""Resolve optional custom personality from app config."""
value = get_config("app.custom_instruction", "")
if value is None:
return None
if not isinstance(value, str):
value = str(value)
if not value.strip():
return None
return value
@staticmethod
def build_payload(
message: str,
model: str,
mode: str = None,
file_attachments: List[str] = None,
tool_overrides: Dict[str, Any] = None,
model_config_override: Dict[str, Any] = None,
image_generation_count: int | None = None,
) -> Dict[str, Any]:
"""Build chat payload for Grok app-chat API."""
attachments = file_attachments or []
payload = {
"deviceEnvInfo": {
"darkModeEnabled": False,
"devicePixelRatio": 2,
"screenWidth": 2056,
"screenHeight": 1329,
"viewportWidth": 2056,
"viewportHeight": 1083,
},
"disableMemory": get_config("app.disable_memory"),
"disableSearch": False,
"disableSelfHarmShortCircuit": False,
"disableTextFollowUps": False,
"enableImageGeneration": True,
"enableImageStreaming": True,
"enableSideBySide": True,
"fileAttachments": attachments,
"forceConcise": False,
"forceSideBySide": False,
"imageAttachments": [],
"imageGenerationCount": image_generation_count
if image_generation_count is not None
else 2,
"isAsyncChat": False,
"isReasoning": False,
"message": message,
"modelName": model,
"responseMetadata": {
"requestModelDetails": {"modelId": model},
},
"returnImageBytes": False,
"returnRawGrokInXaiRequest": False,
"sendFinalMetadata": True,
"temporary": get_config("app.temporary"),
"toolOverrides": tool_overrides or {},
}
if model_config_override:
payload["responseMetadata"]["modelConfigOverride"] = model_config_override
if model == "grok-420":
payload["enable420"] = True
if mode:
payload["modeId"] = mode
elif mode:
payload["modelMode"] = mode
custom_personality = AppChatReverse._resolve_custom_personality()
if custom_personality is not None and "Greet the user" not in message[-1000:]:
payload["customPersonality"] = custom_personality
return payload
@staticmethod
async def request(
session: AsyncSession,
token: str,
message: str,
model: str,
requested_model: str | None = None,
mode: str = None,
file_attachments: List[str] = None,
tool_overrides: Dict[str, Any] = None,
model_config_override: Dict[str, Any] = None,
image_generation_count: int | None = None,
) -> Any:
"""Send app chat request to Grok.
Args:
session: AsyncSession, the session to use for the request.
token: str, the SSO token.
message: str, the message to send.
model: str, the model to use.
mode: str, the mode to use.
file_attachments: List[str], the file attachments to send.
tool_overrides: Dict[str, Any], the tool overrides to use.
model_config_override: Dict[str, Any], the model config override to use.
Returns:
Any: The response from the request.
"""
try:
# Get proxies
base_proxy = get_config("proxy.base_proxy_url")
proxies = {"http": base_proxy, "https": base_proxy} if base_proxy else None
# Build headers
headers = build_headers(
cookie_token=token,
content_type="application/json",
origin="https://grok.com",
referer="https://grok.com/",
)
# Build payload
payload = AppChatReverse.build_payload(
message=message,
model=model,
mode=mode,
file_attachments=file_attachments,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
image_generation_count=image_generation_count,
)
logger.info(
"AppChat request prepared: "
f"requested_model={requested_model or model}, "
f"upstream_model={model}, "
f"mode={mode or '-'}, "
f"message_len={len(message or '')}, "
f"file_attachments={len(file_attachments or [])}, "
f"tools={','.join((tool_overrides or {}).keys()) or '-'}"
)
# Curl Config
base_timeout = max(
float(get_config("chat.timeout") or 60.0),
float(get_config("video.timeout") or 60.0),
float(get_config("image.timeout") or 60.0),
)
connect_timeout = float(
get_config("chat.connect_timeout")
or min(max(base_timeout, 1.0), 12.0)
)
# curl_cffi 支持 (connect_timeout, read_timeout);流读取阶段仍由上层 idle timeout 控制。
timeout = (connect_timeout, base_timeout)
browser = get_config("proxy.browser")
async def _do_request():
try:
response = await session.post(
CHAT_API,
headers=headers,
data=orjson.dumps(payload),
timeout=timeout,
stream=True,
proxies=proxies,
impersonate=browser,
)
except RequestsError as e:
if _is_transient_network_error(e):
raise UpstreamException(
message=f"AppChatReverse transient network error: {e}",
details={"status": 599, "error": str(e)},
) from e
raise
if response.status_code != 200:
# Get response content
content = ""
try:
content = await response.text()
except Exception:
pass
logger.error(
f"AppChatReverse: Chat failed, {response.status_code}",
extra={"error_type": "UpstreamException"},
)
logger.error(f"Response Headers: {response.headers}")
logger.error(f"Response Body: {content}")
raise UpstreamException(
message=f"AppChatReverse: Chat failed, {response.status_code}",
details={"status": response.status_code, "body": content},
)
return response
def extract_status(e: Exception) -> Optional[int]:
if isinstance(e, UpstreamException):
if e.details and "status" in e.details:
status = e.details["status"]
else:
status = getattr(e, "status_code", None)
if status == 429:
return None
return status
return None
response = await retry_on_status(
_do_request,
extract_status=extract_status,
retry_status_codes=[502, 599],
)
# Stream response
async def stream_response():
try:
async for line in response.aiter_lines():
yield line
finally:
try:
close_fn = getattr(response, "aclose", None)
if callable(close_fn):
result = close_fn()
if inspect.isawaitable(result):
await result
else:
close_fn = getattr(response, "close", None)
if callable(close_fn):
result = close_fn()
if inspect.isawaitable(result):
await result
except Exception:
pass
return stream_response()
except Exception as e:
# Handle upstream exception
if isinstance(e, UpstreamException):
status = None
if e.details and "status" in e.details:
status = e.details["status"]
else:
status = getattr(e, "status_code", None)
if status == 401:
try:
await TokenService.record_fail(
token, status, "app_chat_auth_failed"
)
except Exception:
pass
raise
# Handle other non-upstream exceptions
logger.error(
f"AppChatReverse: Chat failed, {str(e)}",
extra={"error_type": type(e).__name__},
)
raise UpstreamException(
message=f"AppChatReverse: Chat failed, {str(e)}",
details={"status": 502, "error": str(e)},
)
__all__ = ["AppChatReverse"]