diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 3eed303..8e7679e 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -140,9 +140,21 @@ getestet). Bei Treffer erzwingt `ToolCallingLLM` die Suche **deterministisch**. **Wichtige Designentscheidung:** *Nicht* per `tool_choice`-Forcen — das honoriert das Modell nicht zuverlässig (beobachtet: forced choice ignoriert → veraltete Antwort). Stattdessen führt der Wrapper die Suche **selbst direkt** aus und speist -das Ergebnis als synthetischen Tool-Turn ein (`_inject_forced_search`); das Modell -formuliert daraus. Metrik: `search_forced_total`. Recall-optimiert (lieber eine -überflüssige Suche als eine konfident falsche Antwort). +das Ergebnis als **System-Kontext** ein (`_inject_forced_search`); das Modell +formuliert daraus. System-Kontext (statt synthetischem tool_call) funktioniert mit +tool-fähigen **und** tool-unfähigen Modellen (letztere lehnen tool-Messages mit 405 +ab). Metrik: `search_forced_total`. Recall-optimiert (lieber eine überflüssige +Suche als eine konfident falsche Antwort). + +### 5.8 Härtung: tool-unfähiges Modell +Wählt jemand (Admin/Preset) ein Modell ohne Tool-fähigen Endpoint, liefert +OpenRouter ein 404 „No endpoints found that support tool use". `ToolCallingLLM` +erkennt das (`_looks_tool_unsupported`), **merkt es für den Turn** (`_tools_unsupported`), +deaktiviert Tools und antwortet **sofort plain** (kein Retry-Hänger, keine stille +Stale-Antwort durch die Fallback-Kette). Log-Warnung + Metrik `tool_unsupported_total`. +Wichtig: Der **Backstop bleibt wirksam** (er speist Fakten als System-Kontext ein, +nicht als tool_call) → heikle Kategorien bleiben auch auf einem tool-unfähigen Modell +korrekt; nur nicht-heikle Volatil-Fragen (Wetter/Preise) fallen auf plain zurück. ## 6. Die zwei harten Stellen (bewusst benannt) @@ -174,8 +186,8 @@ wird zur Tool-Rangordnung). - **Erledigt (nach v1):** Backstop für heikle Kategorien (§5.7), Filler für **Gerät-TTS** (§5.4), **Citations in die UI-Bubble** (`PipelineTrace.citations` → `on_citations` → WS `semantic`-Event → Quellen-Links unter der Antwort). -- **Fast-follow (offen):** „no-tool-endpoints"-404-Härtung (tool-unfähiges Modell - bewusst plain statt still stale), ggf. deterministischer Logistik-Nudge. +- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8). +- **Fast-follow (offen):** ggf. deterministischer Logistik-Nudge (`schedule_hours`). - **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife (Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung. diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 4bc4ae8..27033cf 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -26,6 +26,16 @@ logger = logging.getLogger(__name__) ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" _RETRY_STATUS = {404, 429, 500, 502, 503} +# OpenRouter-404, wenn das Modell keinen Tool-fähigen Endpoint hat. +_TOOL_UNSUPPORTED_HINT = "support tool use" + + +class _ToolsUnsupported(Exception): + """Strukturelles 404: das gewählte Modell kann (über OpenRouter) kein Tool-Calling.""" + + +def _looks_tool_unsupported(status: int, body: str, has_tools: bool) -> bool: + return has_tools and status == 404 and _TOOL_UNSUPPORTED_HINT in (body or "").lower() # Tool-Schema inkl. Trigger-Kategorien — wortgleich zur im Eval validierten Fassung. WEB_SEARCH_TOOL = { @@ -138,6 +148,17 @@ class ToolCallingLLM(LLMProvider): self.max_retries = max(1, max_retries) self.temperature = temperature self.decontextualizer = decontextualizer + # Wird gesetzt, sobald das Modell ein "no tool endpoints"-404 liefert → + # für den Rest des Turns ohne Tools (kein Retry-Hänger, keine stille Stale). + self._tools_unsupported = False + + def _mark_tools_unsupported(self, body: str) -> None: + if not self._tools_unsupported: + logger.warning("Modell %r kann kein Tool-Calling → web_search für diesen Turn " + "deaktiviert (plain). Backstop nutzt weiter direkte Suche. %s", + self.model, (body or "")[:160]) + metrics.inc("tool_unsupported_total") + self._tools_unsupported = True async def _resolve_text(self, text: str, history, language) -> str: """Koreferenz-Vorstufe (gegated) — löst Pronomen aus der History auf.""" @@ -161,7 +182,7 @@ class ToolCallingLLM(LLMProvider): async def _chat(self, messages: list[dict], allow_tools: bool = True) -> dict: """Ein Modell-Aufruf; liefert die Assistant-Message (mit/ohne tool_calls).""" payload = {"model": self.model, "messages": messages, "temperature": self.temperature} - if allow_tools: + if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -171,6 +192,13 @@ class ToolCallingLLM(LLMProvider): for attempt in range(1, self.max_retries + 1): async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post(ENDPOINT, json=payload, headers=headers) + # Modell ohne Tool-Endpoint: einmalig merken, Tools entfernen, sofort + # ohne Tools erneut (kein Retry-Hänger, keine stille Stale). + if _looks_tool_unsupported(resp.status_code, resp.text, "tools" in payload): + self._mark_tools_unsupported(resp.text) + payload.pop("tools", None) + payload.pop("tool_choice", None) + continue if resp.status_code in _RETRY_STATUS and attempt < self.max_retries: last_error = RuntimeError(f"OpenRouter {resp.status_code}: {resp.text[:200]}") await asyncio.sleep(min(2.0 * attempt, 30.0)) @@ -212,7 +240,9 @@ class ToolCallingLLM(LLMProvider): async def _inject_forced_search(self, messages: list[dict], query: str, language: str | None) -> list: """Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das - Ergebnis als synthetischen Tool-Turn einspeisen. Verlässlicher als + Ergebnis als **System-Kontext** einspeisen. Bewusst KEIN synthetischer + tool_call: System-Kontext funktioniert mit tool-fähigen UND tool-unfähigen + Modellen (letztere lehnen tool-Messages mit 405 ab). Verlässlicher als `tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig). Liefert die Citations zurück.""" tool = self.tools.get("web_search") @@ -220,10 +250,12 @@ class ToolCallingLLM(LLMProvider): return [] metrics.inc("tool_calls_total", {"tool": "web_search"}) result = await tool.run(query, language=language) - messages.append({"role": "assistant", "content": None, "tool_calls": [ - {"id": "forced_0", "type": "function", - "function": {"name": "web_search", "arguments": json.dumps({"query": query})}}]}) - messages.append({"role": "tool", "tool_call_id": "forced_0", "content": result.text}) + messages.append({ + "role": "system", + "content": ("Aktuelle Web-Suchergebnisse zur Nutzerfrage (maßgeblich, neuer als " + "dein Trainingswissen):\n" + result.text + + "\n\nBeantworte die Frage des Nutzers auf Basis dieser Ergebnisse."), + }) return list(getattr(result, "citations", []) or []) async def complete(self, text: str, history: list[dict] | None = None, @@ -280,8 +312,14 @@ class ToolCallingLLM(LLMProvider): for _round in range(self.max_rounds): acc = _StreamAcc() - async for piece in self._stream_chat(messages, allow_tools=True, acc=acc): - yield piece + try: + async for piece in self._stream_chat(messages, allow_tools=True, acc=acc): + yield piece + except _ToolsUnsupported: + # Modell ohne Tool-Endpoint -> Runde plain (ohne Tools) wiederholen. + acc = _StreamAcc() + async for piece in self._stream_chat(messages, allow_tools=False, acc=acc): + yield piece if not acc.tool_calls: return # war Text -> fertig (durchgereicht) if on_tool_start is not None: @@ -303,7 +341,7 @@ class ToolCallingLLM(LLMProvider): """Ein gestreamter Modell-Call. Yieldet Text-Deltas; füllt acc (Text + tool_calls).""" payload = {"model": self.model, "messages": messages, "temperature": self.temperature, "stream": True} - if allow_tools: + if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) @@ -312,9 +350,12 @@ class ToolCallingLLM(LLMProvider): async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream("POST", ENDPOINT, json=payload, headers=headers) as resp: if resp.status_code >= 400: - body = await resp.aread() - raise RuntimeError( - f"OpenRouter {resp.status_code}: {body.decode(errors='replace')[:200]}") + body = (await resp.aread()).decode(errors="replace") + # Modell ohne Tool-Endpoint: merken + signalisieren (Caller wiederholt ohne Tools). + if _looks_tool_unsupported(resp.status_code, body, "tools" in payload): + self._mark_tools_unsupported(body) + raise _ToolsUnsupported() + raise RuntimeError(f"OpenRouter {resp.status_code}: {body[:200]}") async for line in resp.aiter_lines(): if not line.startswith("data:"): continue