feat(llm): Härtung gegen tool-unfähiges Modell (no-tool-endpoints-404)

Waehlt jemand (Admin/Preset) ein Modell ohne Tool-faehigen Endpoint, lieferte
OpenRouter ein 404 "No endpoints found that support tool use" -> bisher 4x Retry
mit Backoff und danach stille, konfident-veraltete Fallback-Antwort.

- ToolCallingLLM erkennt das strukturelle 404 (_looks_tool_unsupported), merkt
  es fuer den Turn (_tools_unsupported), deaktiviert Tools und antwortet SOFORT
  plain (kein Haenger). Log-Warnung + Metrik tool_unsupported_total.
- Backstop-Inject jetzt als SYSTEM-Kontext statt synthetischem tool_call: das
  funktioniert mit tool-faehigen UND tool-unfaehigen Modellen (letztere lehnen
  tool-Messages mit 405 ab). Folge: heikle Kategorien bleiben auch auf einem
  tool-unfaehigen Modell korrekt; nur nicht-heikle Volatil-Fragen fallen plain.

Live verifiziert gegen mistral-2501 (tool-unfaehig): 3.7s statt Haenger,
Backstop liefert weiter "Merz"; gegen mistral-3.2 kein Regress (1 Suche).
Tests: 312 gruen. Doc §5.7/§5.8/§8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 00:21:38 +02:00
commit 48a79da12f
2 changed files with 70 additions and 17 deletions

View file

@ -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.

View file

@ -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