DGX LLM Chat Gateway

Server-side Tools

Eine Schicht oberhalb des klassischen OpenAI-Tool-Calling-Patterns: Statt dass nur der Client Tools registriert + ausführt, betreibt rust-api selbst eine Tool-Loop-Engine, die in das /v1/chat/completions-passthrough integriert ist.

Cut 2.23c (ADR 0016) — SPASS-User-Id-Header is mandatory auf /v1/tools/execute und allen tool-using /v1/chat/completions-/c1/chat-Calls. Tool-Audit-Logs schreiben den User für DSGVO-Spur. Body und query user_id werden mit HTTP 400 rejected. Drei Tools sind eingebaut und für jedes tool-fähige Modell (Llama-4-Scout, Mistral-Small-4, Qwen3-VL, Gemma-4, alle Cloud-Flagships) automatisch verfügbar:

ToolZweckBackend
current_datetimeDatum + Uhrzeit, default Timezone Europe/Berlinin-process (chrono + chrono-tz)
calculatorSandbox-safe Mathe (Halluzinations-Schutz)in-process (evalexpr)
web_fetchURL holen, Mozilla-Readability + Plain-Text rendernin-process (reqwest + readability + texting_robots)
web_searchPrivacy-Web-Search (~70 Engines aggregiert)SearXNG-Sidecar im Compose-Stack
wikipedia_summaryDEFAULT für Wikipedia-Fragen, smart-fallback (search + multi-lang)in-process (Wikipedia REST /page/summary + opensearch)
wikipedia_searchFuzzy Title-Suche, Top-N Kandidatenin-process (Wikipedia opensearch-API)
wikipedia_fulltextVoller Artikel-Body für Deep-Researchin-process (Wikipedia parse-API + html2text)
memory_remember / memory_recall / memory_forgetMulti-Tenant Key-Value Memory (private+public, ADR 0003)per-tenant SQLite (Phase B)
memory_describe_scopeDiscovery: counts + bytes + key-list (no values!) für aktuelles SPASS-Scope-Idper-tenant SQLite (Cut 2.12)
list_modelsDiscovery — Catalog-Subset filterbar nach categoryin-process (info::catalog())
image_genText-to-Image, persistiert Blob + 256px Thumbnailself-call /v1/chat/completions (nano-banana default)
rag_queryAktive RAG — top-k Chunks aus tenant-skopiertem Indexper-tenant SQLite + rig InMemoryVectorStore (Cut 2.8)
rag_index_list / rag_index_describe / rag_doc_list / rag_doc_getRead-only RAG-Inspektion via Tools.o. (Cut 2.11)
rag_index_create / rag_doc_append / rag_doc_patchWrite-RAG-Tools mit B2 light-confirm + Previews.o. (Cut 2.11)
rag_index_delete / rag_doc_deleteDestructive RAG-Tools mit B2 + D4 heavy-previews.o. (Cut 2.11)

Alle Tools in TOOLS_ENABLED_DEFAULT werden transparent vor dem Forward zu LiteLLM ins tools[]-Feld injiziert. Antwortet das Modell mit tool_calls, hält rust-api die Konversation, führt die Tools aus, hängt das Result als role: tool-Message an, und re-callt das Modell — bis zu 5× rekursiv. Der Client sieht am Ende nur die finale Antwort plus einen Trace-Header.

RAG-CRUD- und Memory-Tools sind standardmäßig NICHT in der env-Allowlist (opt-in pro Agent via agents/<name>.yaml::tools:-Liste oder direkter /v1/tools/execute-Aufruf). Begründung: Schreib- und destruktive Operationen sollen nicht implizit von jedem Chat-Modell triggerbar sein, ohne dass der Operator/Agent-Designer das explizit absegnet. Schutz auch dann, wenn ein einzelnes Modell prompt-injection-anfällig ist.


Wann das passiert

Modell muss tool-fähig sein. Image-only-Modelle (tools: false im Catalog) bekommen keine Stack-Tools injiziert.

Pro /v1/chat/completions-Request prüft rust-api den tools-Feld-Status im Body:

BodyVerhalten
tools fehlt komplettStack-Tools default-injiziert
tools: [] (leer aber present)Opt-Out-Signal — keine Stack-Tools, keine Loop
tools: [{custom...}]Stack-Tools werden gemerged (Dedup nach function.name, User hat Vorrang)

Streaming-Requests (stream: true) gehen unverändert durch — die Tool-Loop läuft nur für non-streaming. Bei Streaming sieht der Client tool_calls direkt und entscheidet client-side wie er reagiert (OpenAI-Standardpattern).


Tool-Use steuern (Per-Request, 3 Mechanismen + Defense-in-Depth)

Frontend kann pro Request entscheiden ob, welche und wie Tools genutzt werden. Drei orthogonale Mechanismen die sich kombinieren lassen:

1. SPASS-Augment: server-tools=off|on (dgx-spezifisch, Cut 2.20+)

Request-Header der die server-seitige Stack-Tool-Injektion steuert:

POST /c1/chat
Authorization: Bearer …
SPASS-Augment: server-tools=off, system-prompt=on, memory=on

{"model": "godelmann-gocreate-private-llama-text", "message": "Hi"}

2. tools: [] body-field (OpenAI-Spec-Erweiterung)

Body-Feld das client-eigene Tools definiert oder explizit als leer markiert:

// Variante A: keine Tools (legacy Opt-Out auf /v1, neutral auf /c1)
{"model": "...", "messages": [...], "tools": []}

// Variante B: Client schickt eigene Tools, dgx merged Stack-Tools dazu
{"model": "...", "messages": [...], "tools": [{"type":"function","function":{...}}]}
Body-Shape/v1-Verhalten/c1-Verhalten
tools fehltStack-Tools default-injiziertStack-Tools default-injiziert
tools: []Legacy-Opt-Out — keine Stack-Tools, keine Loop"Client hat keine eigenen tools" — Stack-Tools werden trotzdem injiziert (siehe SPASS-Augment zum Deaktivieren)
tools: [{custom}]Stack-Tools werden gemerged (Dedup nach function.name, Client hat Vorrang)gleich

3. tool_choice: "none" | "auto" | "required" | {function} body-field (OpenAI-Spec)

Body-Feld das dem LLM explizit sagt ob es Tools nutzen darf/muss:

{"model": "...", "messages": [...], "tools": [...],
 "tool_choice": "none"}      // LLM darf KEINEN Tool aufrufen, auch wenn welche im tools-spec sind
//"tool_choice": "auto"      // LLM entscheidet selbst (default)
//"tool_choice": "required"  // LLM MUSS einen Tool aufrufen
//"tool_choice": {"type":"function","function":{"name":"calculator"}}  // Force specific tool

OpenAI-1:1-Standard. Alle Modelle inklusive Llama-FP8 versuchen das zu respektieren.

Kombinations-Matrix

Frontend-SetupLLM bekommt tools-spec?LLM-EntscheidungHalluzination-Risiko
Default (nichts gesetzt)Ja (alle Stack-Tools)autoNiedrig — LLM kennt die tools
SPASS-Augment: server-tools=offNeinauto (mangels tools)Mittel — Cut 2.36 Strip greift bei Halluzination
tools: [] body/v1: Nein · /c1: ja, Stack-Toolsautowie default
tools: [] + SPASS-Augment: server-tools=offNeinautowie Variante 2
tool_choice: "none"Ja (wenn tools im body)LLM darf nicht aufrufenMittel — Cut 2.36 Strip greift
Full-no-tools: tools:[] + SPASS-Augment: server-tools=off + tool_choice: "none"NeinLLM darf nichtSehr niedrig — Triple-belt

Defense-in-Depth via Cut 2.36 Hallucinated-Tool-Strip

Llama-FP8 kann trotz aller 3 Mechanismen halluziniert einen Tool-Call als JSON-Content emittieren (Modell-Quirk durch Training-Bias). Cut 2.36 fängt das automatisch:

Cut 2.39c Generic Pseudo-Tool Text-Recovery + Cut 2.39 Multi-Turn-Feedback-Loop

Cut 2.36 strip war passive-only — content="" empty bei strip. Cut 2.39c + Cut 2.39 sind die proaktive Recovery-Schicht:

Order-of-Operations:

LLM-Response → tool_normalize.try_normalize_llama_tool_call(...)
                     ↓
              Stripped { reason, names }
                     ↓
       ┌─────────────┴──────────────────────────┐
       ↓                                        ↓
  Cut 2.39c: pseudo-tool name + plausible    Cut 2.39c NICHT-match (echo-back oder
             string-param                                non-whitelist name)
  → content = plausible string               → content = "" (sanitize-strip)
       ↓                                        ↓
  v1.rs: content non-empty                  v1.rs: content empty
  → SKIP Cut 2.39                           → Cut 2.39 try_feedback_loop_for_unknown_tool
  → emit spass.tool-stripped                     → bei success: spass.tool-feedback-recovery
                                                 → bei exhausted: spass.tool-stripped (Cut 2.36 fallback)

Siehe /docs/changelog — Cut 2.20 (tool_normalize original), Cut 2.32 (CR-0001 Multi-Tool-Synth + partial-match), Cut 2.36 (CR-0007 hallucinated-tool-strip), Cut 2.36b (stream-mode strip-event), Cut 2.38 (stream-strip-gap fix), Cut 2.38b (Llama-respond-quirk recovery), Cut 2.39 (Multi-Turn-Feedback-Loop), Cut 2.39c (Generic-Pseudo-Tool-Recovery mit Echo-Back-Schutz). SSE-event details in examples-streaming.md.

Verifikation per curl

# Privacy-Mode probieren
curl -X POST https://dgx.spass.fun/c1/chat \
  -H "Authorization: Bearer $BEARER" -H "SPASS-User-Id: $USER_ID" \
  -H "SPASS-Augment: server-tools=off" \
  -d '{"model":"godelmann-gocreate-private-llama-text","message":"Hi","stream":false}' \
  -D - -o /dev/null | grep -i spass-augment-applied
# → spass-augment-applied: system-prompt=…, server-tools=off, memory=…

# Mit tool_choice=none (zero-tool-guarantee zusätzlich)
curl -X POST https://dgx.spass.fun/c1/chat \
  -H "Authorization: Bearer $BEARER" -H "SPASS-User-Id: $USER_ID" \
  -H "SPASS-Augment: server-tools=off" \
  -d '{"model":"godelmann-gocreate-private-llama-text","message":"Hi","tools":[],"tool_choice":"none"}'

current_datetime

Schema (so injiziert rust-api das Tool ins Modell):

{
  "type": "function",
  "function": {
    "name": "current_datetime",
    "description": "Get the current date and time in a given IANA timezone. Default Europe/Berlin. Returns ISO-8601 local and UTC plus a human-readable German form.",
    "parameters": {
      "type": "object",
      "properties": {
        "timezone": { "type": "string", "default": "Europe/Berlin" }
      }
    }
  }
}

Output (was das Modell als role: tool-Antwort sieht):

{
  "iso_local": "2026-05-01T06:43:01+02:00",
  "iso_utc":   "2026-05-01T04:43:01+00:00",
  "human_de":  "Freitag, 1. Mai 2026, 06:43 Uhr (CEST)",
  "tz":        "Europe/Berlin",
  "offset":    "+02:00",
  "weekday":   "friday",
  "weekday_de":"Freitag"
}

Edge-Cases:


web_fetch

Schema:

{
  "type": "function",
  "function": {
    "name": "web_fetch",
    "description": "Fetch the contents of a web URL and return cleaned, readable text. HTTPS only. Body capped at 2 MB. robots.txt is respected.",
    "parameters": {
      "type": "object",
      "properties": {
        "url":            { "type": "string", "description": "HTTPS URL" },
        "max_chars":      { "type": "integer", "minimum": 200, "maximum": 50000, "default": 8000 },
        "respect_robots": { "type": "boolean", "default": true }
      },
      "required": ["url"]
    }
  }
}

Pipeline (5 Validation-Stufen + 2 Extraction-Layer):

  1. URL parsen + HTTPS-only enforce

  2. robots.txt prüfen (1 h Cache pro Host) — error: "robots_disallowed" wenn verboten

  3. HTTP GET mit 15 s Timeout, 5 Redirects max, 2 MB Body-Cap

  4. Cloudflare-Detection: HTTP 403/503 mit cf-mitigated-Header oder "Just a moment..."-HTML im Body → error: "cloudflare_challenge" (kein Bypass-Versuch)

  5. Body-Read

  6. Mozilla-Readability extrahiert article-content-Region (boilerplate-frei)

  7. html2text rendert in Plain-Text (Headers, Listen, Links)

Fallback-Pfad wenn Readability nichts findet (kein <article>, leerer Content): html2text auf full body.

Output:

{
  "url":           "https://de.wikipedia.org/wiki/Bratwurst",
  "title":         "Bratwurst – Wikipedia",
  "content":       "Die Bratwurst ist...\n* Nürnberger Rostbratwurst\n...[truncated]",
  "truncated":     true,
  "bytes_fetched": 487291
}

Failure-JSON-Shapes (Modell sieht alle als role: tool-content, kann recovern):

errorTrigger
https_requiredSchema ≠ https
robots_disallowedrobots.txt verbietet User-Agent
unreachable / timeoutConnect-Fehler oder >15 s
http_4xx / http_5xxUpstream-Status-Code
cloudflare_challengeCF-Header oder Interstitial-HTML
body_too_large> 2 MB
body_read_failedI/O nach Header-OK

Headless-Browser-Bypass für Cloudflare ist bewusst nicht implementiert. Camoufox/SeleniumBase-Setup mit Residential-Proxies wäre Wettrüsten 2026 und Multi-Service-Sprawl. BACKLOG-Item für später.


web_search

Schema:

{
  "type": "function",
  "function": {
    "name": "web_search",
    "description": "Search the web using SearXNG (~70 engines aggregated, privacy-respecting).",
    "parameters": {
      "type": "object",
      "properties": {
        "query":  { "type": "string", "minLength": 1, "maxLength": 500 },
        "top_n":  { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 },
        "lang":   { "type": "string", "default": "de-DE" }
      },
      "required": ["query"]
    }
  }
}

Pipeline:

  1. Query-Validation in rust-api (1–500 Zeichen, trim)
  2. HTTP GET an SearXNG-Sidecar: http://searxng:8080/search?q=...&format=json&language=...&safesearch=1
  3. SearXNG fragt parallel 8–12 Engines (Google, Bing, DuckDuckGo, Wikipedia, GitHub, ...)
  4. rust-api filtert top_n (default 5, max 10) + capped Snippet auf 400 Zeichen + behält nur {url, title, snippet, engine}

Output:

{
  "query": "DGX Spark Preis 2026",
  "results": [
    {
      "url":     "https://www.heise.de/news/...",
      "title":   "Nvidia DGX Spark: Preiserhöhung um 17,5 Prozent",
      "snippet": "Der KI-Mini-Computer DGX Spark wird teurer...",
      "engine":  "google"
    },
    { "url": "...", "title": "...", "snippet": "...", "engine": "bing" }
  ],
  "result_count": 5,
  "total_available": 47
}

Privacy-Eigenschaften:

Failure-JSON-Shapes:

errorTrigger
search_unavailableSearXNG-Container nicht erreichbar
http_<code>SearXNG returned 5xx
parse_errorSearXNG-Response ist kein JSON

Wikipedia (drei Tools, hierarchisch)

Eskalations-Hierarchie für Wikipedia-Lookups — Modell wählt nach Bedarf:

  1. wikipedia_summary (DEFAULT) — kurzer Extract via REST /page/summary mit smart-fallback: bei 404 automatisch opensearch + retry mit Top-1, dann fallback-language (de → en). Deckt 90% der Use-Cases mit einem Tool-Call ab.
  2. wikipedia_search — fuzzy Title-Suche via opensearch-API, returns bis zu 10 Kandidaten mit URL+Snippet. Use-Case: wenn wikipedia_summary die falsche Disambiguation traf oder Modell mehrere Kandidaten will.
  3. wikipedia_fulltext — kompletter Artikel-Body via parse-API, HTML→Plain-Text gerendert, max_chars-cap (default 8000, max 50000). Use-Case: Deep-Research, "schreibe einen Vortrag über X".

Schemas

// 1) wikipedia_summary (DEFAULT für Wikipedia-Fragen)
{
  "name": "wikipedia_summary",
  "parameters": {
    "title": "string",         // Albert Einstein, Berliner Mauer, Effi Briest
    "lang": "string",          // default "de"
    "fallback_lang": "string"  // default "en"
  }
}

// Output:
{
  "title": "Albert Einstein",
  "extract": "Albert Einstein war ein theoretischer Physiker...",
  "url": "https://de.wikipedia.org/wiki/Albert_Einstein",
  "thumbnail_url": "https://upload.wikimedia.org/.../Einstein_1921.jpg",
  "lang_used": "de",
  "search_was_needed": false,
  "is_disambiguation": false
}
// 2) wikipedia_search (fuzzy queries)
{
  "name": "wikipedia_search",
  "parameters": {
    "query": "string",   // "fontane roman effi"
    "lang": "string",    // default "de"
    "limit": "integer"   // default 5, max 10
  }
}

// Output:
{
  "query": "fontane roman",
  "lang": "de",
  "candidate_count": 5,
  "candidates": [
    {"title": "Effi Briest", "snippet": "...", "url": "..."},
    {"title": "Theodor Fontane", "snippet": "...", "url": "..."}
  ]
}
// 3) wikipedia_fulltext (deep research)
{
  "name": "wikipedia_fulltext",
  "parameters": {
    "title": "string",       // exact title — use resolved title from summary
    "lang": "string",        // default "de"
    "max_chars": "integer"   // default 8000, min 500, max 50000
  }
}

// Output:
{
  "title": "Bratwurst",
  "lang": "de",
  "url": "https://de.wikipedia.org/wiki/Bratwurst",
  "full_text": "Als Bratwurst werden verschiedene Wurstsorten...\n\n## Geschichte\n...[truncated]",
  "sections": [{"level": 1, "title": "Geschichte", "anchor": "Geschichte"}, ...],
  "section_count": 16,
  "truncated": true
}

Smart-Fallback bei wikipedia_summary (kritisch)

1. Try {lang}.wikipedia.org/page/summary/{title}     ← direct hit
2. If 404 → opensearch in {lang}, retry summary with Top-1
3. If still 404 → try {fallback_lang}.wikipedia.org/page/summary/{title}
4. If still 404 → opensearch in {fallback_lang}
5. If all fail → {"error": "not_found", "lang_tried": [lang, fallback_lang]}

search_was_needed: true im Response markiert wenn opensearch-Fallback genutzt wurde — Modell weiß damit dass die Title-Resolution nicht-trivial war.

Latency-Profil (gemessen)

Failure-Modes

errorTrigger
wikipedia_unreachableNetwork/DNS-Fehler
not_foundAuch nach Suche keine Treffer in beiden Sprachen
mediawiki_errorAPI gibt strukturierten error zurück (z.B. invalid title)
parse_errorAPI-Response nicht-JSON
http_<code>Non-2xx HTTP

Privacy


Response-Header x-rust-api-tools-executed

Damit der Client (Browser-Playground, eigener API-Konsument) die Tool-Loop-Spur sehen kann — die finale Response enthält ja keine tool_calls mehr (rust-api hat sie im Loop bereits aufgelöst).

Format: name:iter Pairs, comma-separiert.

HTTP/1.1 200 OK
content-type: application/json
x-rust-api-tools-executed: web_search:1,web_fetch:2

Heißt: Modell rief in iteration 1 web_search, sah die Treffer, rief in iteration 2 dann web_fetch auf einem dieser URLs, dann produzierte es die finale Antwort.

Header fehlt wenn keine Stack-Tools im Loop liefen (entweder weil das Modell keine Tools rief, oder weil die tool_calls zu User-defined-Tools gingen, die rust-api nicht ausführt).


Loop-Schutzmechanismen

LimitWertVerhalten beim Hit
Max Iterationen5Letzte Response zurückgeben (mit unresolved tool_calls drin), WARN-Log
Body-Größe pro Tool-Response32 MB (MAX_BODY_BYTES)InternalError (sollte nie passieren)
HTTP-Timeout für web_fetch15 serror: timeout JSON ans Modell
HTTP-Timeout für web_search15 serror: search_unavailable JSON ans Modell
robots.txt-Check-Timeout5 sbei Fail: convention-fallback (allowed)

Unbekannte tool_calls (Modell ruft Tool, das rust-api nicht kennt — z.B. ein User-defined-Tool aus dem Body): rust-api passt das durch. Client kriegt die tool_calls und entscheidet client-side. Standard- OpenAI-Pattern bleibt erhalten für eigene Tools.


Aktivierung pro Modell

Steuerung in mehreren Schichten:

  1. Stack-Allowlist (TOOLS_ENABLED_DEFAULT env, default current_datetime,calculator,web_fetch,web_search,wikipedia_*): nur Tools in dieser Liste werden in Chat-Completions automatisch injiziert. RAG-CRUD- und Memory-Tools sind bewusst NICHT default — opt-in pro Agent (agents/.yaml tools: [...]) oder über /v1/tools/execute direkt.
  2. Per-Modell (available_tools Feld in /v1/info-Catalog): jedes tool-fähige Modell erbt die Liste aus default_available_tools(). Image-only-Modelle bleiben leer.
  3. Per-Token (constraints.tools in tokens.yaml, ADR 0003): Default ["*"]. Bei einem konkreten Tool-Allowlist-Array filtert das die Schnittmenge weiter ein.
  4. Per-Tenant für tool-defaults (Cut 2.13 / ADR 0013): z.B. image_gen_default_model, image_gen_rate_per_hour werden über den 3-Ebenen-Cascade (/v1/tenant/config) aufgelöst — siehe /docs/tenant-config.

Der finale Inject-Schritt nimmt die Schnittmenge aus (1) + (2) + (3). Tool-runtime-Verhalten (default model, rate-cap, ttl) wird zusätzlich durch (4) beeinflusst.


Architektur-Hinweise