Luca448's picture
Savestate: Fix Dockerfile and add Browser_Agent for HF build, bump version to 1.8.334
508316a
Raw History Blame Contribute Delete
34 kB
# 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()