# agent/brain.py import json import logging import asyncio import random import httpx from dataclasses import dataclass, field from playwright.async_api import Page from agent.tool_schemas import TOOLS, SYSTEM_PROMPT from agent.tools import execute_tool from agent.tool_registry import TOOL_SPECS from config.settings import settings # NOTE: Security/stealth checks can also be enforced here if necessary. logger = logging.getLogger(__name__) OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" @dataclass class AgentSession: task: str messages: list = field(default_factory=list) step_count: int = 0 total_tokens: int = 0 estimated_cost_usd: float = 0.0 _LARGE_OBSERVATION_FIELDS = ("dom_text", "text_content", "image_base64") _OBSERVATION_TOOLS = { "read_dom", "read_text_content", "find_element", "extract_structured", "visual_check", "list_tabs", "search_web", } def _compact_processed_observations(messages: list[dict]) -> None: """Drop bulky page observations only after the model consumed them once.""" for index, message in enumerate(messages): if message.get("role") != "tool": continue try: decoded = json.loads(message.get("content", "{}")) except Exception: continue if not isinstance(decoded, dict) or not any( field in decoded for field in _LARGE_OBSERVATION_FIELDS ): continue compacted_fields = [] for field in _LARGE_OBSERVATION_FIELDS: value = decoded.pop(field, None) if value is not None: compacted_fields.append(f"{field}:{len(str(value))}") decoded["observation_compacted"] = ",".join(compacted_fields) decoded["message"] = "Frühere Seitenbeobachtung wurde nach Verarbeitung komprimiert." messages[index]["content"] = json.dumps( decoded, ensure_ascii=False, separators=(",", ":") ) def _compact_history_result(result: object, max_chars: int = 1200) -> str: """Keep useful prior-task context while removing transport payloads.""" import re text = str(result or "") text = re.sub(r"\[HANDOFF_REQUIRED\|.*?\]", "[Browser-Handoff angefordert]", text) text = re.sub(r"\[EPHEMERAL_IMAGE\|.*?\]", "[Bild bereitgestellt]", text) return text[:max_chars] def _tools_for_model(model: str) -> list[dict]: """Return tools whose results are safe for a text-only main model.""" return [ tool for tool in TOOLS if tool["function"]["name"] != "request_temp_mail_bypass" ] class CircuitBreakerError(Exception): """Wird geworfen wenn Circuit Breaker offen ist oder Budget überschritten.""" pass class AgentIncompleteError(RuntimeError): """The model stopped without a verified completion tool.""" pass class ModelAPIError(RuntimeError): def __init__( self, message: str, *, status_code: int | None = None, retry_after: float | None = None, ): super().__init__(message) self.status_code = status_code self.retry_after = retry_after @property def retryable(self) -> bool: return self.status_code is None or self.status_code in {408, 429} or bool( self.status_code and self.status_code >= 500 ) class _CircuitBreakerState: """ 3-Zustand-Automat zur Verhinderung kaskadierender Fehler (OWASP ASI08). Zustände: CLOSED → Normalbetrieb, alle Anfragen passieren. OPEN → Blockade nach max_failures aufeinanderfolgenden Fehlern. Alle Anfragen werden sofort abgelehnt (cooldown_seconds). HALF-OPEN → Nach Cooldown: Ein einzelner Probe-Request darf durch. Erfolg → CLOSED. Fehler → sofort wieder OPEN. """ def __init__(self, max_failures: int = 5, cooldown_seconds: float = 60.0): self.max_failures = max_failures self.cooldown_seconds = cooldown_seconds self._failures = 0 self._state = "CLOSED" # CLOSED | OPEN | HALF-OPEN self._opened_at: float = 0.0 self._half_open_probe_in_flight = False def record_success(self): self._failures = 0 self._state = "CLOSED" self._half_open_probe_in_flight = False def record_failure(self): self._failures += 1 if self._state == "HALF-OPEN" or self._failures >= self.max_failures: import time self._state = "OPEN" self._opened_at = time.monotonic() self._half_open_probe_in_flight = False logger.error( f"Circuit Breaker GEÖFFNET nach {self._failures} Fehlern. " f"Cooldown: {self.cooldown_seconds}s" ) def allow_request(self) -> bool: import time if self._state == "CLOSED": return True if self._state == "OPEN": elapsed = time.monotonic() - self._opened_at if elapsed >= self.cooldown_seconds: self._state = "HALF-OPEN" self._half_open_probe_in_flight = True logger.info("Circuit Breaker → HALF-OPEN: Probe-Request erlaubt") return True return False if self._state == "HALF-OPEN": if self._half_open_probe_in_flight: return False self._half_open_probe_in_flight = True return True return False @property def state(self) -> str: return self._state def _describe_tool_call(name: str, args: dict) -> tuple[str, str]: """Erzeugt Claude-Code-artige informative Titel und Kurzbeschreibungen für jeden Schritt.""" if name == "navigate": url = args.get("url", "") return "🌐 Seite laden", f"Navigiere zu: {url}" elif name == "search_web": q = args.get("query", "") return "🔍 Web-Suche", f'Suche nach: "{q}"' elif name == "create_temp_account": u = args.get("target_url", "") return "📧 Wegwerf-Account", f"Registrierung auf: {u}" elif name == "click": nid = args.get("node_id", "") return "👆 Element anklicken", f"Klicke Node #{nid}" elif name == "type_text": nid = args.get("node_id", "") val = str(args.get("value", "")) if any(k in str(nid).lower() for k in ["pass", "pwd", "secret", "pin"]): masked_val = "••••••••" else: masked_val = f'"{val}"' if len(val) <= 25 else f'"{val[:22]}..."' return "⌨️ Text eingeben", f"Feld #{nid}: {masked_val}" elif name == "read_dom": return "📄 Seite analysieren", "Lese Webseiten-Struktur & Accessibility Tree" elif name == "read_text_content": return "📄 Text auslesen", "Extrahiere sichtbaren Text der Webseite" elif name == "find_element": q = args.get("query", "") return "🔎 Element suchen", f'Suche nach: "{q}"' elif name == "visual_check": return "Visueller Check", str(args.get("goal", "Prüfe den aktuellen Viewport"))[:80] elif name == "generate_image_and_deliver": p = args.get("prompt", "") return "🎨 Bild generieren", f'Prompt: "{p[:40]}..."' elif name == "define_plan": steps_list = args.get("steps", []) return "📋 Plan erstellt", f"{len(steps_list)} Schritte geplant" elif name == "update_step_status": idx = args.get("step_index", 0) st = args.get("status", "") return "🔄 Status aktualisieren", f"Schritt {idx + 1} auf '{st}'" elif name == "request_user_permission": r = args.get("reason", "") return "🛡️ Erlaubnis anfragen", f"Grund: {r}" elif name == "request_browser_handoff": return "🤝 Browser Handoff", "Interaktives Login / Captcha erforderlich" elif name == "task_complete": return "✅ Fertigstellen", "Ergebnis wird aufbereitet" else: return f"⚙️ Tool: {name}", f"Argumente: {str(args)[:60]}" def _format_tool_result_summary(name: str, args: dict, result: any) -> str: """Formatiert das Ergebnis eines Werkzeugs prägnant für die UI-Timeline.""" if not isinstance(result, dict): return str(result)[:80] if result.get("error"): return f"Fehler: {str(result['error'])[:80]}" if name == "navigate": return "Seite erfolgreich geladen" elif name == "search_web": cnt = len(result.get("results", [])) return f"{cnt} Suchergebnisse gefunden" elif name == "click": return f"Klick auf Element #{args.get('node_id')} ausgeführt" elif name == "type_text": return "Text erfolgreich eingetragen" elif name == "read_dom": dom_len = len(result.get("dom_text", "")) return f"Webseite analysiert ({dom_len} Zeichen)" elif name == "read_text_content": txt_len = len(result.get("text_content", "")) return f"Text extrahiert ({txt_len} Zeichen)" elif name == "visual_check": report = result.get("visual_check") or {} return str(report.get("summary") or "Visueller Check abgeschlossen")[:80] elif name == "create_temp_account": if result.get("status") == "success": return f"Account erstellt: {result.get('email', '')}" elif result.get("status") == "handoff_required": return "Captcha erkannt - Handoff erforderlich" return str(result.get("message", "Fertig"))[:80] elif name == "generate_image_and_deliver": return "Bild erfolgreich erzeugt" return str(result.get("message", "Erfolgreich ausgeführt"))[:80] class AgentBrain: """ Kernlogik des Browser-Agenten. Koordiniert DeepSeek-Aufrufe und Tool-Ausführungen in einer Agentic Loop. Enthält Circuit Breaker (OWASP ASI08) und Token-Budget-Kontrolle. """ def __init__(self): self._client = httpx.AsyncClient(timeout=60.0) self._circuit_breakers: dict[str, _CircuitBreakerState] = {} def _breaker_for(self, model: str) -> _CircuitBreakerState: if model not in self._circuit_breakers: self._circuit_breakers[model] = _CircuitBreakerState( max_failures=5, cooldown_seconds=60.0, ) return self._circuit_breakers[model] async def _call_api_with_resilience( self, messages: list[dict], *, model: str | None, api_key: str | None, session_id: str | None, allow_fallback: bool, ) -> dict: primary = model or settings.openrouter_model models = [primary] if allow_fallback and settings.fallback_model and settings.fallback_model != primary: models.append(settings.fallback_model) last_error: Exception | None = None for model_index, candidate in enumerate(models): max_attempts = max(1, settings.model_max_attempts) if model_index == 0 else 1 breaker = self._breaker_for(candidate) for attempt in range(1, max_attempts + 1): if settings.circuit_breaker_enabled and not breaker.allow_request(): last_error = CircuitBreakerError( f"Circuit Breaker für {candidate} ist OPEN." ) break try: response = await self._call_api( messages, model=candidate, api_key=api_key, session_id=session_id, ) if not response.get("choices"): raise ModelAPIError( "Modellantwort enthält keine choices.", status_code=502, ) breaker.record_success() return response except (httpx.TransportError, httpx.TimeoutException) as exc: last_error = exc retryable = True retry_after = None except ModelAPIError as exc: last_error = exc retryable = exc.retryable retry_after = exc.retry_after except Exception as exc: last_error = exc retryable = False retry_after = None breaker.record_failure() if not retryable or attempt >= max_attempts: break delay = retry_after if retry_after is not None else (2 ** (attempt - 1)) delay += random.uniform(0, min(0.5, delay / 4)) await asyncio.sleep(delay) if last_error: raise last_error raise ModelAPIError("Kein Modell konnte die Anfrage verarbeiten.") async def run(self, instruction: str, page: Page, model: str | None = None, task=None) -> str: """ Führt eine Aufgabe durch die vollständige Agentic Loop aus. Berücksichtigt eine optionale conversation_history (Liste von {role, content} Dicts) damit der Agent sich an vorherige Aufgaben in dieser Chat-Session erinnert. """ import datetime current_time = datetime.datetime.now().strftime("%A, %Y-%m-%d %H:%M:%S") # Injiziere Konfigurationsstatus in System-Prompt, damit Agent # keine unnötigen Rückfragen stellt (z.B. ob DDG Token vorhanden) config_notes = [] duck_token_available = False if task is not None: task_params = task.params if isinstance(task.params, dict) else {} duck_from_params = task_params.get("duckduckgo_token", "") or "" duck_from_settings = getattr(settings, "duckduckgo_token", "") or "" duck_token = duck_from_params or duck_from_settings or "" duck_token_available = bool(duck_token and duck_token.strip()) logger.info(f"[DIAG] brain.run DDG token check: from_params_len={len(duck_from_params)}, from_settings_len={len(duck_from_settings)}, available={duck_token_available}") if duck_token_available: config_notes.append("- DUCKDUCKGO_TOKEN: VORHANDEN. Du kannst `create_temp_account` DIREKT aufrufen, ohne den Nutzer nach einer E-Mail-Adresse zu fragen. Das Tool erstellt automatisch einen @duck.com Alias.") else: config_notes.append("- DUCKDUCKGO_TOKEN: NICHT konfiguriert. Als Fallback wird mail.tm verwendet.") config_block = "\n".join(config_notes) dynamic_system_prompt = ( f"{SYSTEM_PROMPT}\n\n" f"[SYSTEM INFO]\n" f"Die aktuelle Serverzeit (und das heutige Datum) ist: {current_time}.\n" f"Nutze dieses Datum als absoluten Referenzpunkt für alle Ereignisse.\n\n" f"[KONFIGURATIONSSTATUS]\n{config_block}" ) session = AgentSession(task=instruction) session.messages.append({"role": "system", "content": dynamic_system_prompt}) # Chatgedächtnis: Vorherige Aufgaben aus conversation_history einbetten if task is not None: history = task.params.get("conversation_history", []) if history: # Jede vergangene Aufgabe als kompakte Zusammenfassung einbetten history_text = "\n".join( f"[Frühere Aufgabe {i+1}]\n" f"Nutzer: {str(h.get('prompt', ''))[:600]}\n" f"Ergebnis: {_compact_history_result(h.get('result', 'Kein Ergebnis'))}\n" for i, h in enumerate(history[-5:]) # Maximal die letzten 5 Tasks ) session.messages.append({ "role": "user", "content": f"[KONTEXT — vorherige Aufgaben dieser Chat-Session]\n{history_text}\n(Ende des Kontexts)" }) session.messages.append({ "role": "assistant", "content": "Verstanden. Ich habe den Kontext der vorherigen Aufgaben gelesen und werde ihn berücksichtigen." }) session.messages.append({"role": "user", "content": f"AUFGABE:\n{instruction}"}) unverified_text_count = 0 last_mutation_revision = 0 last_observation_revision = -1 # Initialer Start-Schritt für Live-Feedback if task is not None and not task.steps: task.steps.append({ "title": "Analysiere Anfrage & plane Vorgehen", "subtitle": "Agent verbindet sich mit Browser & prüft Aufgabe...", "status": "active", "time": datetime.datetime.now().strftime("%H:%M:%S") }) while session.step_count < settings.max_agent_steps: session.step_count += 1 try: logger.info(f"Schritt {session.step_count}: API Aufruf...") api_key_param = task.params.get("api_key") if task else None api_response = await self._call_api_with_resilience( session.messages, model=model, api_key=api_key_param, session_id=task.id if task else None, allow_fallback=False, ) # Every tool observation currently in the request has now # been consumed once. Compact it before any later agent turn; # new tool results are appended below and remain intact for # their first model call. _compact_processed_observations(session.messages) except Exception as e: logger.error(f"API Fehler: {e}") if task is not None and task.steps: task.steps.append({ "title": "API-Fehler", "subtitle": str(e)[:100], "status": "failed", "time": datetime.datetime.now().strftime("%H:%M:%S") }) raise e # Message history aktualisieren (mit Fehlerbehandlung für malformed responses) choices = api_response.get("choices") if not choices: # OpenRouter lieferte keine choices -> Fehler sauber melden error_detail = api_response.get("error", {}).get("message") or str(api_response)[:200] logger.error(f"OpenRouter lieferte keine 'choices': {error_detail}") if task is not None: task.steps.append({ "title": "⚠️ KI-Modell Fehler", "subtitle": f"Keine Antwort vom Modell: {error_detail[:100]}", "status": "failed", "time": datetime.datetime.now().strftime("%H:%M:%S") }) raise ValueError(f"OpenRouter Fehler – kein Ergebnis vom Modell. Details: {error_detail}") message = choices[0]["message"] session.messages.append(message) # Initialen Planungs-Schritt abschließen falls noch aktiv if task is not None and task.steps and task.steps[0].get("status") == "active": task.steps[0]["status"] = "done" # OpenRouter returns native token counts and the actual charged # cost for every response. The former implementation added the # cumulative token total again on every step, making the estimate # grow quadratically and causing false budget aborts. usage = api_response.get("usage", {}) or {} response_tokens = int(usage.get("total_tokens") or 0) prompt_tokens = int(usage.get("prompt_tokens") or 0) completion_tokens = int(usage.get("completion_tokens") or 0) reasoning_tokens = int( (usage.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0 ) cached_tokens = int( (usage.get("prompt_tokens_details") or {}).get("cached_tokens") or 0 ) session.total_tokens += response_tokens response_cost = usage.get("cost") if response_cost is None: # Compatibility fallback for providers/tests that omit cost. logger.warning( "OpenRouter usage.cost fehlt; verwende konservative Token-Fallbackschätzung." ) response_cost = (response_tokens / 1000) * 0.0001 session.estimated_cost_usd += float(response_cost) logger.info( "OpenRouter usage: prompt=%d completion=%d reasoning=%d cached=%d " "total=%d cost=%.6f session_tokens=%d session_cost=%.6f", prompt_tokens, completion_tokens, reasoning_tokens, cached_tokens, response_tokens, float(response_cost), session.total_tokens, session.estimated_cost_usd, ) if session.estimated_cost_usd > settings.token_budget_usd: raise CircuitBreakerError("Token Budget überschritten!") tool_calls = message.get("tool_calls") or [] # Modell-Gedanken / Zwischenupdates als Schritt festhalten thought = (message.get("content") or "").strip() if thought and tool_calls and task is not None: task.steps.append({ "title": "💡 Überlegung", "subtitle": thought if len(thought) <= 150 else thought[:147] + "...", "status": "done", "time": datetime.datetime.now().strftime("%H:%M:%S") }) if not tool_calls: # Kein Tool-Call -> Direktes Text-Ergebnis res_content = message.get("content", "Aufgabe abgeschlossen.") if task is not None: unverified_text_count += 1 if unverified_text_count <= 2: session.messages.append({ "role": "user", "content": ( "Die Aufgabe ist noch nicht verifiziert abgeschlossen. " "Nutze ein Browser-Werkzeug oder `task_complete` mit dem " "tatsächlich geprüften Ergebnis." ), }) continue raise AgentIncompleteError( "Das Modell beendete die Aufgabe ohne verifizierten Abschluss." ) if task is not None: task.steps.append({ "title": "✅ Antwort bereitgestellt", "subtitle": "Direkte Auswertung abgeschlossen", "status": "done", "time": datetime.datetime.now().strftime("%H:%M:%S") }) return res_content # Tool-Calls ausführen for tool_call in tool_calls: func_name = tool_call["function"]["name"] # Parse arguments try: func_args = json.loads(tool_call["function"]["arguments"]) except Exception as e: logger.error(f"Fehler beim Parsen der Argumente für {func_name}: {e}") session.messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps({ "status": "error", "error_code": "invalid_tool_arguments", "error": "Tool-Argumente waren kein gültiges JSON.", }, ensure_ascii=False), }) continue if func_name == "task_complete": if last_observation_revision < last_mutation_revision: session.messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps({ "status": "error", "error_code": "verification_required", "error": ( "Beobachte den aktuellen Seitenzustand nach der letzten Aktion " "mit read_dom, read_text_content oder visual_check." ), }, ensure_ascii=False), }) continue final_result = func_args.get("result", "Aufgabe abgeschlossen.") logger.info(f"Aufgabe abgeschlossen nach {session.step_count} Schritten.") # Anti-Halluzinations-Sperre: Prüfe ob ein Fake-Account-Token vorliegt if "[ACCOUNT_CREATED|" in final_result: # Prüfe, ob in den Tool-Ergebnissen der Session ein Fehler bei create_temp_account war had_failed_creation = False for msg in session.messages: if msg.get("role") == "tool": try: t_data = json.loads(msg.get("content", "{}")) if isinstance(t_data, dict) and (t_data.get("status") == "error" or t_data.get("success") is False): had_failed_creation = True break except Exception: pass if had_failed_creation: logger.warning("Blockiere halluziniertes [ACCOUNT_CREATED|...] Token wegen fehlgeschlagenem Tool-Aufruf!") import re final_result = re.sub(r'\[ACCOUNT_CREATED\|.*?\]', '', final_result).strip() if not final_result: final_result = "Die Account-Erstellung ist leider fehlgeschlagen (die Website blockiert automatisierte Anmeldungen oder verlangt OAuth)." if task is not None: task.steps.append({ "title": "✅ Aufgabe abgeschlossen", "subtitle": "Ergebnis übermittelt", "status": "done", "time": datetime.datetime.now().strftime("%H:%M:%S") }) return final_result # Schritt in Timeline aufnehmen (außer reine interne Plan-Tools) step_entry = None if task is not None and func_name not in ["define_plan", "update_step_status"]: step_title, step_sub = _describe_tool_call(func_name, func_args) step_entry = { "title": step_title, "subtitle": step_sub, "status": "active", "time": datetime.datetime.now().strftime("%H:%M:%S") } task.steps.append(step_entry) logger.info( "Führe Tool aus: %s (Argumente: %s)", func_name, ", ".join(sorted(func_args.keys())) or "keine", ) tool_result = await execute_tool(func_name, func_args, page, task=task) tool_failed = isinstance(tool_result, dict) and ( tool_result.get("error") is not None or tool_result.get("status") == "error" or tool_result.get("success") is False ) if not tool_failed: revision = int(tool_result.get("snapshot_revision") or 0) spec = TOOL_SPECS.get(func_name) if spec and spec.invalidates_snapshot: last_mutation_revision = max(last_mutation_revision, revision) if func_name in _OBSERVATION_TOOLS: last_observation_revision = max(last_observation_revision, revision) if func_name == "visual_check": report = tool_result.get("visual_check") or {} session.estimated_cost_usd += float(report.get("costUsd") or 0.0) if session.estimated_cost_usd > settings.token_budget_usd: raise CircuitBreakerError( "Token Budget durch visuellen Check überschritten!" ) # Schritt aktualisieren if step_entry is not None: step_entry["status"] = "failed" if tool_failed else "done" step_entry["subtitle"] = _format_tool_result_summary(func_name, func_args, tool_result) # A human handoff is a terminal backend result. Returning it # directly avoids a second LLM call whose only job was to echo # a potentially very large cookie payload verbatim. if isinstance(tool_result, dict) and tool_result.get("status") == "handoff_required": handoff_signal = tool_result.get("handoff_signal") if handoff_signal: logger.info( "Browser-Handoff als strukturierter Task-Zustand zurückgegeben" ) return tool_result return tool_result if isinstance(tool_result, dict) and tool_result.get("status") == "confirmation_required": return tool_result # Tool Response an den LLM zurückgeben session.messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps(tool_result, ensure_ascii=False), }) raise AgentIncompleteError( f"Maximale Schrittzahl ({settings.max_agent_steps}) erreicht." ) async def _call_api( self, messages: list[dict], model: str | None = None, api_key: str | None = None, session_id: str | None = None, ) -> dict: """ Ruft die OpenRouter API mit DeepSeek auf. KRITISCH: Die Sicherheitsparameter müssen im `provider`-Objekt stehen. """ selected_model = model or settings.openrouter_model selected_tools = _tools_for_model(selected_model) payload = { "model": selected_model, "messages": messages, "tools": selected_tools, "tool_choice": "auto", "max_tokens": 65536, "temperature": 0.2, "reasoning": {"effort": "medium"}, } if session_id: payload["session_id"] = session_id # Keep every task on one exact ZDR endpoint so prompt caches survive. payload["provider"] = { "only": [settings.openrouter_provider], "allow_fallbacks": False, "data_collection": "deny", "zdr": True, } api_key = api_key or settings.openrouter_api_key if not api_key: raise ValueError("FEHLENDER API KEY: Bitte füge deinen 'OPENROUTER_API_KEY' in den Settings deines Hugging Face Spaces (unter Variables and secrets) hinzu oder gib ihn in der App ein!") message_chars = sum(len(json.dumps(message, ensure_ascii=False)) for message in messages) logger.info( "OpenRouter request: model=%s, messages=%d, tools=%d, message_chars=%d", selected_model, len(messages), len(selected_tools), message_chars, ) response = await self._client.post( OPENROUTER_URL, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://huggingface.co/spaces/gravity-agent", "X-Title": "Gravity Browser Agent", }, json=payload, ) if response.status_code >= 400: error_body = response.text[:500] retry_after = None try: retry_after = float(response.headers.get("Retry-After", "")) except (TypeError, ValueError): retry_after = None raise ModelAPIError( f"OpenRouter API-Fehler {response.status_code}: {error_body}", status_code=response.status_code, retry_after=retry_after, ) try: payload = response.json() except Exception as exc: raise ModelAPIError( "OpenRouter lieferte ungültiges JSON.", status_code=502, ) from exc if not isinstance(payload, dict): raise ModelAPIError( "OpenRouter lieferte ein ungültiges Antwortformat.", status_code=502, ) return payload async def close(self): await self._client.aclose()