fix(llm): kaputte Werkzeug-Argumente im Verlauf lassen nicht mehr die ganze Anfrage scheitern

Zwischendurch kam bei Programmen (Software-Schmiede) "model_error: Server
error '500 …'" — nach "nochmal versuchen" lief es weiter. Ursache, auf der
Box nachgestellt: steht im Verlauf ein frueherer Werkzeugaufruf, dessen
arguments kein gueltiges JSON sind (typisch bei Code: Dateiinhalt mit
Anfuehrungszeichen/Umbruechen), lehnt llama.cpp (Stand der Box, Aug. 2026)
die GANZE Anfrage mit 500 ab — und jede weitere mit demselben Verlauf.
Neuere llama.cpp-Versionen nehmen es an.

llm-adapter:
- _sanitize_messages: solche arguments werden als {"_raw": "…"} verpackt,
  dict-arguments zu JSON, fehlende zu "{}". Nur Kopien; gueltige bleiben
  unangetastet. Auf der Box: kaputt → 500, eingepackt → 200.
- _llama_error_text: bei HTTP-Fehlern kommt der GRUND von llama.cpp mit
  (z.B. "request (9017 tokens) exceeds the available context size (8192)")
  statt nur "Server error '500'". Volle Antwort ins Box-Log — falls es noch
  einen anderen Ausloeser gibt, steht er dort.

Hinweis: ob das der einzige Ausloeser ist, ist offen — die urspruengliche
Fehlerantwort war 4,6 KB gross, diese Ablehnung nur 265 Byte. Mit dem
durchgereichten Grund ist der naechste Fall sofort zu sehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 02:56:42 +02:00
co-authored by Claude Opus 5.5
parent 32d34f39cd
commit f8ae2f4613
+60 -5
View File
@@ -317,6 +317,62 @@ async def _send(ws, mtype: str, payload: dict) -> None:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e) logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
def _sanitize_messages(messages: list) -> list:
"""Verlauf so aufbereiten, dass llama.cpp ihn nicht als Ganzes ablehnt.
Steht im Verlauf ein frueherer Werkzeugaufruf, dessen arguments KEIN
gueltiges JSON sind, antwortet llama.cpp auf die GANZE Anfrage mit 500
("Failed to parse tool call arguments as JSON") — und jede weitere Anfrage
mit demselben Verlauf auch. Bei Programmen, die Code schreiben, passiert
das leicht (Dateiinhalt mit Anfuehrungszeichen/Umbruechen). Statt den
ganzen Auftrag scheitern zu lassen: solche Argumente als {"_raw": "…"}
verpacken. Ein dict als arguments (statt String) wird zu JSON.
Nur Kopien — die Nachrichten des Aufrufers bleiben unveraendert."""
out = []
for m in messages or []:
tcs = m.get("tool_calls") if isinstance(m, dict) else None
if not tcs:
out.append(m)
continue
fixed = []
for tc in tcs:
fn = dict((tc or {}).get("function") or {})
args = fn.get("arguments")
if isinstance(args, (dict, list)):
fn["arguments"] = json.dumps(args, ensure_ascii=False)
elif isinstance(args, str):
try:
json.loads(args or "{}")
if not args.strip():
fn["arguments"] = "{}"
except Exception:
logger.warning("Werkzeugaufruf %r im Verlauf hat kein gueltiges JSON als "
"arguments (%d Zeichen) — eingepackt", fn.get("name"), len(args))
fn["arguments"] = json.dumps({"_raw": args}, ensure_ascii=False)
elif args is None:
fn["arguments"] = "{}"
fixed.append(dict(tc, function=fn))
out.append(dict(m, tool_calls=fixed))
return out
def _llama_error_text(exc: Exception) -> str:
"""Bei HTTP-Fehlern den GRUND von llama.cpp mitnehmen. Bisher kam nur
"Server error '500 …'" an — die eigentliche Erklaerung steht im Body."""
resp = getattr(exc, "response", None)
if resp is None:
return re.sub(r"\s*For more information check:\s*\S+", "", str(exc)).strip()
detail = ""
try:
j = resp.json()
e = j.get("error") if isinstance(j, dict) else None
detail = (e.get("message") if isinstance(e, dict) else e) or json.dumps(j)[:600]
except Exception:
detail = (resp.text or "")[:600]
logger.warning("llama.cpp HTTP %s — Antwort: %s", resp.status_code, (resp.text or "")[:2000])
return f"llama.cpp HTTP {resp.status_code}: {str(detail).strip()[:600]}"
async def _call_llama(messages: list, *, max_tokens: int, temperature: float, async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop, tools=None, model=None, timeout: float | None = None) -> dict: stop, tools=None, model=None, timeout: float | None = None) -> dict:
"""Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt """Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt
@@ -328,7 +384,7 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
natives Tool-Calling und liefert dann message.tool_calls.""" natives Tool-Calling und liefert dann message.tool_calls."""
body = { body = {
"model": model or LLM_MODEL, "model": model or LLM_MODEL,
"messages": messages, "messages": _sanitize_messages(messages),
"max_tokens": max_tokens, "max_tokens": max_tokens,
"temperature": temperature, "temperature": temperature,
"stream": False, "stream": False,
@@ -361,10 +417,9 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
"usage": usage, "usage": usage,
} }
except Exception as e: except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e) msg = _llama_error_text(e)
# httpx haengt "For more information check: <Link>" an — fuer niemanden hilfreich. logger.warning("llama.cpp-Call fehlgeschlagen: %s", msg[:300])
msg = re.sub(r"\s*For more information check:\s*\S+", "", str(e)).strip() return {"ok": False, "content": "", "error": msg[:700]}
return {"ok": False, "content": "", "error": msg[:300]}
# B0.5-2: Lade-Status ans Diagnostic (service_status, service="llm"). Wir kennen # B0.5-2: Lade-Status ans Diagnostic (service_status, service="llm"). Wir kennen