import asyncio import json import logging import random from typing import AsyncGenerator, Tuple import httpx import config from auth_manager import auth_manager import fingerprint import timing logger = logging.getLogger(__name__) def _content_to_text(content) -> str: if isinstance(content, list): return "".join( part.get("text", "") for part in content if isinstance(part, dict) and part.get("type") == "text" ) return str(content or "") def _build_prompt(messages: list) -> str: if not messages: return "Hello" lines = [] for msg in messages: role = msg.get("role", "user") text = _content_to_text(msg.get("content", "")).strip() if not text: continue lines.append(f"{role}: {text}") if not lines: return "Hello" return "\n".join(lines) def _resolve_model(model_name: str) -> Tuple[str, str]: if model_name in config.MODEL_MAP: return config.MODEL_MAP[model_name] if "__" in model_name: parts = model_name.split("__") if len(parts) >= 3: return parts[0], parts[-1] # Safe fallback for unknown names return "Anthropic", "claude-opus-4-6" def _random_referer() -> str: paths = [ "/app", "/app/chat", f"/app/chat/{random.randint(100, 9999)}", "/app/search", ] return f"{config.ONYX_BASE_URL}{random.choice(paths)}" def _headers(email: str, with_json: bool = False) -> dict: persona = fingerprint.get_persona_for_account(email) headers = fingerprint.get_base_headers(persona) headers["origin"] = config.ONYX_BASE_URL headers["referer"] = _random_referer() headers["accept"] = "application/json" if with_json: headers["content-type"] = "application/json" return headers def _build_cookies(auth_cookie: str, csrf_cookie: str) -> dict: """构建包含 auth 和 csrf 的完整 cookies 字典。""" cookies = {"fastapiusersauth": auth_manager._extract_auth_value(auth_cookie)} if csrf_cookie: cookies["fastapiusersoauthcsrf"] = csrf_cookie return cookies async def create_chat_session_with_cookie(client: httpx.AsyncClient) -> Tuple[str, str, str, str]: # 最大重试次数 = 账号总数(确保每个账号都有机会被尝试),至少 3 次 max_attempts = max(len(auth_manager.accounts), 3) last_error = "" for attempt in range(max_attempts): try: auth_cookie, csrf_cookie, email = await auth_manager.get_valid_cookie(client) except RuntimeError as e: # 所有账号都已不可用 raise RuntimeError(f"所有账号均不可用: {e}") payload = {"persona_id": config.ONYX_PERSONA_ID, "description": None, "project_id": None} response = await client.post( f"{config.ONYX_BASE_URL}/api/chat/create-chat-session", headers=_headers(email, with_json=True), json=payload, cookies=_build_cookies(auth_cookie, csrf_cookie), timeout=httpx.Timeout(config.REQUEST_TIMEOUT, connect=15.0), ) if response.status_code == 401: logger.warning("create_chat_session HTTP 401: Cookie 过期,尝试刷新... (第 %d/%d 次)", attempt + 1, max_attempts) await auth_manager.report_unauthorized(client, auth_cookie) continue elif response.status_code == 403: logger.warning("create_chat_session HTTP 403: 额度耗尽或被封禁,尝试重新登录后切换账号 (第 %d/%d 次)", attempt + 1, max_attempts) await auth_manager.report_forbidden(client, auth_cookie) last_error = "403 Forbidden" continue if response.status_code != 200: raise RuntimeError(f"Onyx create-chat-session HTTP {response.status_code}: {response.text[:300]}") data = response.json() chat_session_id = data.get("chat_session_id") or data.get("id") if not chat_session_id: raise RuntimeError(f"create-chat-session missing chat_session_id: {data}") return chat_session_id, auth_cookie, csrf_cookie, email raise RuntimeError(f"所有 {max_attempts} 个账号均已尝试失败 (最后错误: {last_error})") async def create_chat_session(client: httpx.AsyncClient) -> str: session_id, _, _, _ = await create_chat_session_with_cookie(client) return session_id async def _delete_session_bg( client: httpx.AsyncClient, chat_session_id: str, auth_cookie: str, csrf_cookie: str, email: str ) -> None: try: await timing.micro_delay("between_requests") await client.request( "DELETE", f"{config.ONYX_BASE_URL}/api/chat/delete-chat-session/{chat_session_id}", headers=_headers(email, with_json=True), cookies=_build_cookies(auth_cookie, csrf_cookie), timeout=5.0, ) except Exception as e: logger.debug("Failed to background delete chat session %s: %s", chat_session_id, e) async def stream_chat( client: httpx.AsyncClient, messages: list, model_name: str, ) -> AsyncGenerator[Tuple[str, str], None]: created_sessions = [] has_yielded_data = False # 方案 A:跟踪是否已经开始发送数据 try: # 确保 session 和后续消息使用同一个 cookie(同时携带 CSRF token) chat_session_id, auth_cookie, csrf_cookie, email = await create_chat_session_with_cookie(client) created_sessions.append((chat_session_id, auth_cookie, csrf_cookie, email)) provider, version = _resolve_model(model_name) payload = { "message": _build_prompt(messages), "chat_session_id": chat_session_id, "parent_message_id": None, "file_descriptors": [], "internal_search_filters": { "source_type": None, "document_set": None, "time_cutoff": None, "tags": [], }, "deep_research": False, "forced_tool_id": None, "llm_override": { "temperature": 0.5, "model_provider": provider, "model_version": version, }, "origin": config.ONYX_ORIGIN, } await timing.micro_delay("typing") for attempt in range(3): async with client.stream( "POST", f"{config.ONYX_BASE_URL}/api/chat/send-chat-message", headers=_headers(email, with_json=True), json=payload, cookies=_build_cookies(auth_cookie, csrf_cookie), timeout=httpx.Timeout(config.REQUEST_TIMEOUT, connect=15.0), ) as response: if response.status_code == 401: if attempt < 2 and not has_yielded_data: logger.warning("stream_chat HTTP 401: Cookie expired. Refreshing token... (第 %d/3 次)", attempt + 1) await auth_manager.report_unauthorized(client, auth_cookie) # 需要使用刷新后的 token 重建完整会话 chat_session_id, auth_cookie, csrf_cookie, email = await create_chat_session_with_cookie(client) created_sessions.append((chat_session_id, auth_cookie, csrf_cookie, email)) payload["chat_session_id"] = chat_session_id continue else: raise RuntimeError("Onyx stream_chat failed - token expired and retry failed") elif response.status_code == 403: logger.warning("stream_chat HTTP 403: Quota exhausted or account banned.") await auth_manager.report_forbidden(client, auth_cookie) if attempt < 2 and not has_yielded_data: chat_session_id, auth_cookie, csrf_cookie, email = await create_chat_session_with_cookie(client) created_sessions.append((chat_session_id, auth_cookie, csrf_cookie, email)) payload["chat_session_id"] = chat_session_id continue else: raise RuntimeError("Onyx stream_chat failed due to HTTP 403 Forbidden") if response.status_code != 200: body = await response.aread() raise RuntimeError(f"Onyx send-chat-message HTTP {response.status_code}: {body.decode(errors='replace')[:300]}") async for line in response.aiter_lines(): if not line: continue line = line.strip() if not line: continue try: item = json.loads(line) except json.JSONDecodeError: continue obj = item.get("obj", {}) item_type = obj.get("type") if item_type == "reasoning_delta": delta = obj.get("reasoning", "") if delta: has_yielded_data = True yield "thinking", delta elif item_type == "message_delta": delta = obj.get("content", "") if delta: has_yielded_data = True yield "text", delta elif item_type == "stop": break # Successfully finished without 401 break finally: for sid, auth, csrf, sid_email in created_sessions: asyncio.create_task(_delete_session_bg(client, sid, auth, csrf, sid_email)) async def full_chat(client: httpx.AsyncClient, messages: list, model_name: str) -> Tuple[str, str]: text_parts = [] thinking_parts = [] async for item_type, content in stream_chat(client, messages, model_name): if item_type == "thinking": thinking_parts.append(content) else: text_parts.append(content) return "".join(text_parts), "".join(thinking_parts)