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/executeund allen tool-using/v1/chat/completions-/c1/chat-Calls. Tool-Audit-Logs schreiben den User für DSGVO-Spur. Body und queryuser_idwerden 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:
| Tool | Zweck | Backend |
|---|---|---|
current_datetime | Datum + Uhrzeit, default Timezone Europe/Berlin | in-process (chrono + chrono-tz) |
calculator | Sandbox-safe Mathe (Halluzinations-Schutz) | in-process (evalexpr) |
web_fetch | URL holen, Mozilla-Readability + Plain-Text rendern | in-process (reqwest + readability + texting_robots) |
web_search | Privacy-Web-Search (~70 Engines aggregiert) | SearXNG-Sidecar im Compose-Stack |
wikipedia_summary | DEFAULT für Wikipedia-Fragen, smart-fallback (search + multi-lang) | in-process (Wikipedia REST /page/summary + opensearch) |
wikipedia_search | Fuzzy Title-Suche, Top-N Kandidaten | in-process (Wikipedia opensearch-API) |
wikipedia_fulltext | Voller Artikel-Body für Deep-Research | in-process (Wikipedia parse-API + html2text) |
memory_remember / memory_recall / memory_forget | Multi-Tenant Key-Value Memory (private+public, ADR 0003) | per-tenant SQLite (Phase B) |
memory_describe_scope | Discovery: counts + bytes + key-list (no values!) für aktuelles SPASS-Scope-Id | per-tenant SQLite (Cut 2.12) |
list_models | Discovery — Catalog-Subset filterbar nach category | in-process (info::catalog()) |
image_gen | Text-to-Image, persistiert Blob + 256px Thumbnail | self-call /v1/chat/completions (nano-banana default) |
rag_query | Aktive RAG — top-k Chunks aus tenant-skopiertem Index | per-tenant SQLite + rig InMemoryVectorStore (Cut 2.8) |
rag_index_list / rag_index_describe / rag_doc_list / rag_doc_get | Read-only RAG-Inspektion via Tool | s.o. (Cut 2.11) |
rag_index_create / rag_doc_append / rag_doc_patch | Write-RAG-Tools mit B2 light-confirm + Preview | s.o. (Cut 2.11) |
rag_index_delete / rag_doc_delete | Destructive RAG-Tools mit B2 + D4 heavy-preview | s.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:
| Body | Verhalten |
|---|---|
tools fehlt komplett | Stack-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"}
server-tools=off— dgx injiziert KEINE Stack-Tools (calculator, web_search, wikipedia_, current_datetime, image_gen, rag_, memory_*, list_models). Privacy-Mode.server-tools=on(default) — alle Tools derTOOLS_ENABLED_DEFAULT-Allowlist werden injiziert.- Response-Header
SPASS-Augment-Applied: server-tools=off(bzw.server-tools=<comma-list>) zeigt was tatsächlich angewendet wurde.
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 fehlt | Stack-Tools default-injiziert | Stack-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-Setup | LLM bekommt tools-spec? | LLM-Entscheidung | Halluzination-Risiko |
|---|---|---|---|
| Default (nichts gesetzt) | Ja (alle Stack-Tools) | auto | Niedrig — LLM kennt die tools |
SPASS-Augment: server-tools=off | Nein | auto (mangels tools) | Mittel — Cut 2.36 Strip greift bei Halluzination |
tools: [] body | /v1: Nein · /c1: ja, Stack-Tools | auto | wie default |
tools: [] + SPASS-Augment: server-tools=off | Nein | auto | wie Variante 2 |
tool_choice: "none" | Ja (wenn tools im body) | LLM darf nicht aufrufen | Mittel — Cut 2.36 Strip greift |
Full-no-tools: tools:[] + SPASS-Augment: server-tools=off + tool_choice: "none" | Nein | LLM darf nicht | Sehr 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:
- Wenn
request_had_tools=false(=tools[]leer oder fehlt im payload zum LLM) UND Llama emittiert eine{"name":"<tool>",...}-Struktur im content → server strippt den JSON-Block aus content, preserves Prose drumherum. - Wenn der Name unknown zur Stack-Registry → auch gestripped (egal ob
request_had_toolswar). - Response-Body bekommt zwei additive Felder:
{ "dgx_code": "hallucinated_tool_stripped_all_unknown", // oder _unrequested_known "stripped_tool_names": ["translate_text"], "choices": [...] // content ist clean, JSON weg, Prose erhalten } - Frontend kann die
dgx_code-Felder ignorieren (content ist bereits sauber) oder als safety-net-Hint nutzen.
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:
- Cut 2.39c (Generic Pseudo-Tool Text-Recovery mit Echo-Back-Schutz): Wenn Llama-FP8 ein 1-candidate-pseudo-tool emittiert (
name ∈ {respond, text, answer, output, reply, message, say, response, content, speak, tell, write}), extrahiert der Server einen plausiblen string-parameter alscontent. Strategie: Key-Priority (response, text, content, message, answer, reply, ...) zuerst, dann längster plausibler string. Plausibility-Filter lehnt JSON/Python-array ([{...}]), JSON-object ({...}), HTML (<...>) und chat-shape-echo ('type':,'role':,'messages':) ab — schützt vor Echo-Back-Contamination der user-message.0 extra-LLM-calls. Wenn kein plausibler string-param → content="" + Cut 2.39 feedback-loop greift downstream. - Cut 2.39 (Multi-Turn-Feedback-Loop für andere unknown-tools): Wenn ein unknown-Tool emittiert wird das Cut 2.39c NICHT recovered hat (Name nicht in Whitelist, oder kein plausible param) → Server hängt eine
system-message an payload.messages ("Tool 'X' not available. Available: [...]. Please respond without it.") und re-callt upstream bis zu 3 retries (separat vonMAX_TOOL_ITERATIONS=10). Bei success:dgx_code: "tool_feedback_recovery_after_<reason>"+ SSE-eventspass.tool-feedback-recovery. Bei exhausted (3 retries stubborn): Cut 2.36-strip-fallback.
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:
- Unbekannte Timezone →
{ "error": "invalid_arguments", "message": "unknown IANA timezone: ..." }— Modell sieht den Error und kann mit anderer TZ retry. - Kein I/O, kein externes Backend → kann nie hängen.
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):
-
URL parsen + HTTPS-only enforce
-
robots.txt prüfen (1 h Cache pro Host) —
error: "robots_disallowed"wenn verboten -
HTTP GET mit 15 s Timeout, 5 Redirects max, 2 MB Body-Cap
-
Cloudflare-Detection: HTTP 403/503 mit
cf-mitigated-Header oder "Just a moment..."-HTML im Body →error: "cloudflare_challenge"(kein Bypass-Versuch) -
Body-Read
-
Mozilla-Readability extrahiert article-content-Region (boilerplate-frei)
-
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):
error | Trigger |
|---|---|
https_required | Schema ≠ https |
robots_disallowed | robots.txt verbietet User-Agent |
unreachable / timeout | Connect-Fehler oder >15 s |
http_4xx / http_5xx | Upstream-Status-Code |
cloudflare_challenge | CF-Header oder Interstitial-HTML |
body_too_large | > 2 MB |
body_read_failed | I/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:
- Query-Validation in rust-api (1–500 Zeichen, trim)
- HTTP GET an SearXNG-Sidecar:
http://searxng:8080/search?q=...&format=json&language=...&safesearch=1 - SearXNG fragt parallel 8–12 Engines (Google, Bing, DuckDuckGo, Wikipedia, GitHub, ...)
- 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:
- SearXNG läuft als Sidecar (kein public port, nur Compose-internal)
- Engines wie Google sehen die Query, aber nicht die End-User-IP (immer SearXNG-Container-IP)
- Kein Vendor-Lock-In, keine API-Quote, keine externen Keys
- Brave-Free-Tier wurde Feb 2026 abgeschafft — SearXNG ist die kostenfreie Privacy-Alternative
Failure-JSON-Shapes:
error | Trigger |
|---|---|
search_unavailable | SearXNG-Container nicht erreichbar |
http_<code> | SearXNG returned 5xx |
parse_error | SearXNG-Response ist kein JSON |
Wikipedia (drei Tools, hierarchisch)
Eskalations-Hierarchie für Wikipedia-Lookups — Modell wählt nach Bedarf:
wikipedia_summary(DEFAULT) — kurzer Extract via REST/page/summarymit 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.wikipedia_search— fuzzy Title-Suche via opensearch-API, returns bis zu 10 Kandidaten mit URL+Snippet. Use-Case: wennwikipedia_summarydie falsche Disambiguation traf oder Modell mehrere Kandidaten will.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)
wikipedia_summarydirect hit: ~150-200 mswikipedia_summarymit search-fallback: ~400 ms (2 API-Calls)wikipedia_search: ~80-250 mswikipedia_fulltext(8000 chars cap): ~400-500 ms
Failure-Modes
error | Trigger |
|---|---|
wikipedia_unreachable | Network/DNS-Fehler |
not_found | Auch nach Suche keine Treffer in beiden Sprachen |
mediawiki_error | API gibt strukturierten error zurück (z.B. invalid title) |
parse_error | API-Response nicht-JSON |
http_<code> | Non-2xx HTTP |
Privacy
- Wikipedia-Anfragen gehen direkt an
*.wikipedia.org(kein Vendor-Proxy) - Wikipedia-Foundation loggt minimal (User-Agent + IP+, keine Query-Inhalte außer als Web-Server-Log mit Wikimedia-Privacy-Policy)
- Public-Domain-Daten, kein API-Key, keine Quote (für single-user uncritical)
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
| Limit | Wert | Verhalten beim Hit |
|---|---|---|
| Max Iterationen | 5 | Letzte Response zurückgeben (mit unresolved tool_calls drin), WARN-Log |
| Body-Größe pro Tool-Response | 32 MB (MAX_BODY_BYTES) | InternalError (sollte nie passieren) |
HTTP-Timeout für web_fetch | 15 s | error: timeout JSON ans Modell |
HTTP-Timeout für web_search | 15 s | error: search_unavailable JSON ans Modell |
| robots.txt-Check-Timeout | 5 s | bei 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:
- Stack-Allowlist (
TOOLS_ENABLED_DEFAULTenv, defaultcurrent_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/executedirekt. - Per-Modell (
available_toolsFeld in/v1/info-Catalog): jedes tool-fähige Modell erbt die Liste ausdefault_available_tools(). Image-only-Modelle bleiben leer. - Per-Token (
constraints.toolsintokens.yaml, ADR 0003): Default["*"]. Bei einem konkreten Tool-Allowlist-Array filtert das die Schnittmenge weiter ein. - Per-Tenant für tool-defaults (Cut 2.13 / ADR 0013): z.B.
image_gen_default_model,image_gen_rate_per_hourwerden ü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
- rust-api ist die Tool-Loop-Heimat, nicht LiteLLM. Begründung:
LiteLLM kann tools server-side nur für
/v1/responsesausführen, nicht für/v1/chat/completions(unser einziger Chat-Endpoint). - Tool-Trait ist transport-agnostisch.
Tool::execute(args, ctx)ist isomorph zum MCP-Tool-Call-Protokoll. BACKLOG: ein zukünftigerbin/mcp-server.rsexposed dieselben Tools per stdio für Claude Desktop / Continue / Zed-AI ohne Code-Refactor. - Streaming ist nicht Tool-Loop-fähig. Bei
stream: truereicht rust-api durch, Client-side handling. Server-side-Loop für Streaming wäre möglich (interne SSE-Akkumulation), wurde aber als BACKLOG zurückgestellt — die Mehrheit der API-Caller nutzt non-streaming. - SearXNG ist nur auf Spark 1, internal-only. Der einzige Konsument ist die rust-api-Tool-Layer auf derselben Maschine — kein Cross- Maschinen-Hop nötig, auch wenn das Modell auf der Station inferiert.