Multi-Provider Routing
Dev-Fassung mit Code-Walks — geprüft gegen wintermute@47ec498 (Stand 2026-07-03).
TL;DR: Du willst nicht an einen LLM-Anbieter gekettet sein. Wintermute nutzt LiteLLM als Abstraktion und drei konfigurierbare Modell-Slots (Default, Thinking, Fast), die der User per Slash-Command pro Turn oder dauerhaft umschalten kann — plus
/localfür Modelle auf eigener Hardware. Damit ist Anbieter-Wechsel eine Config-Zeile, kein Refactoring.
Das Problem
Wer einen Agenten an einen einzigen LLM-Anbieter koppelt, baut sich drei Probleme gleichzeitig ein:
- Lock-in. Preise ändern sich, Anbieter ziehen Modelle zurück, Rate-Limits werden enger. Du bist Geisel.
- Falsche Werkzeug-Wahl pro Aufgabe. Ein 80€-pro-Million- Tokens-Frontier-Modell für eine Frage wie was ist 47 × 132? ist Verschwendung. Ein 1€-pro-Million-Tokens-Mini-Modell für ein Architektur-Review ist Frust.
- Provider-spezifischer Code. Anthropics SDK gibt typisierte
tool_use-Blöcke zurück. OpenAIs SDK gibttool_calls-Arrays. Moonshots Kimi-Modelle haben einenextra_body-Parameter für Thinking-Modus. Wer das in seinem Agenten direkt verdrahtet, hat bei jedem Anbieter-Wechsel halb-tagesweise Refactoring.
Das eigentliche Problem ist Optionalität: du willst die Möglichkeit, je nach Aufgabe, Preis-Druck oder Verfügbarkeit das Modell zu wechseln — ohne dass dich das Code-Änderungen kostet.
Unsere Lösung
Wintermute löst das durch vier Designentscheidungen, die zusammenwirken:
flowchart LR
U["User-Nachricht<br/>evtl. mit Slash-Command"]
P["<b>Slash-Command-Parser</b><br/>/think, /fast, /local, /model"]
DS["<b>Default-Slot</b><br/>DEFAULT_MODEL<br/>z.B. claude-opus-4-7"]
TS["<b>Thinking-Slot</b><br/>THINKING_MODEL<br/>z.B. kimi-k2.6"]
FS["<b>Fast-Slot</b><br/>FAST_MODEL<br/>z.B. kimi-k2.5"]
LM["<b>Local-Alias-Map</b><br/>LOCAL_MODELS<br/>plus OLLAMA_API_BASE"]
ST["<b>Sticky Override</b><br/>pro Conversation<br/>in-memory"]
L["<b>LiteLLM</b><br/>einheitliches Interface<br/>Tool-Call-Normalisierung"]
PR1["Anthropic"]
PR2["Moonshot"]
PR3["OpenAI"]
PR4["Ollama remote<br/>via Tailscale"]
PR5["weitere"]
U --> P
P -->|"normal"| DS
P -->|"/think"| TS
P -->|"/fast"| FS
P -->|"/local alias"| LM
P -->|"/model X"| ST
ST --> DS
DS --> L
TS --> L
FS --> L
LM --> L
L --> PR1
L --> PR2
L --> PR3
L --> PR4
L --> PR5
classDef abstraction fill:#dbeafe,stroke:#2563eb,stroke-width:2px;
class L abstraction;
Die vier Bausteine
- LiteLLM als Abstraktion. LiteLLM ist eine Python-Bibliothek, die das Interface zu allen großen LLM-Anbietern vereinheitlicht. Du rufst
litellm.acompletion(model="anthropic/claude-opus-4-7", messages=[...])auf — und genausolitellm.acompletion(model="moonshot/kimi-k2.6", messages=[...]). LiteLLM kümmert sich um: SDK-Wahl, Authentifizierung, Response-Shape-Übersetzung (z.B. Anthropics typisierte Blöcke in OpenAIstool_calls), und Provider-spezifische Eigenheiten. Dein Code spricht ein Interface, egal welcher Anbieter dahinter steht. - Drei Modell-Slots. Statt das Modell hardzucoden, hat Wintermute drei konfigurierbare Slots:
DEFAULT_MODEList das Alltagsmodell (Defaultanthropic/claude-opus-4-7),THINKING_MODELwird von/think <message>für einen Turn aktiviert (Reasoning-Modell, langsamer und teurer, Defaultmoonshot/kimi-k2.6),FAST_MODELvon/fast <message>(billig und schnell, Defaultmoonshot/kimi-k2.5). - Sticky Override pro Conversation. Mit
/model <name>schaltest du das Default für diese Conversation um, bis du/model resetmachst oder der Agent neu startet. In-memory, nicht persistent — passt zur Wintermute-Philosophie: Konversations-Zustand ist flüchtig, nur Langzeit-Memory persistiert. - Lokale Modelle per Alias. Mit
/local <alias> <message>routest du einen Turn an ein Ollama auf eigener Hardware — z.B. einen Rechner, der per Tailscale (VPN-Mesh) erreichbar ist. Der Operator definiert inLOCAL_MODELSeine Alias-Map (qwen=ollama/qwen3:32b,...);OLLAMA_API_BASEzeigt auf den Remote-Server./localohne Argumente listet die konfigurierten Aliase. Damit kostet ein Experiment mit einem lokalen Modell keinen Cloud-Cent und keine Config-Änderung.
Was die Lösung leistet
- Anbieter-Wechsel = eine ENV-Variable.
DEFAULT_MODEL= openai/gpt-4oin.envsetzen, durchdocker-compose.ymldurchschleifen, neu starten — fertig. Kein Code-Diff. - Per-Aufgabe-Routing per Slash-Command. Triviale Frage?
/fast. Schwieriges Problem?/think. Datenschutz-sensibel oder Experiment?/local. Der User entscheidet pro Turn. - Allowlist als Sicherheitsnetz.
ALLOWED_MODELS=...schränkt ein, was/model <name>akzeptiert — verhindert Tippfehler oder Versehen ($$)./think,/fastund/localsind bewusst ausgenommen: sie können nur Modelle wählen, die der Operator ohnehin vorkuratiert hat (Slot-ENV-Vars bzw. dieLOCAL_MODELS-Map). - Tool-Use bleibt anbieterunabhängig. Weil LiteLLM Tool-Calls auf OpenAI-Format normalisiert, sind die Tools aus Kap 05 nicht an einen Anbieter gekoppelt.
Trade-offs & offene Fragen
- Nicht jedes Modell kann alles. Tool-Use ist nicht universell. Manche kleinen lokalen Modelle können keine Tool-Calls. Manche Reasoning-Modelle sind langsam bei langen Tool-Loops. Du musst pro Slot ein passendes Modell wählen.
- Provider-Quirks sickern durch. LiteLLM abstrahiert
meistens, aber nicht immer. Moonshots
kimi-k2.6hat einenextra_body={"thinking": {...}}-Parameter, den Wintermute aktuell nicht durchschleift (sieheMODELS.md, Abschnitt "Deferred work"). Anthropic-Caching-Pricing folgt anderen Regeln als OpenAI. Wer das letzte Prozent rausholen will, muss die Provider-Doku trotzdem lesen. - Modell-IDs in Doku ≠ verfügbar für deinen Account. Sehr
reale Stolperfalle (LESSONS §13): Provider-Dokus listen
Modelle, die nicht jeder API-Schlüssel freigeschaltet hat.
kimi-k2-thinkingist in Moonshots Doku — aber nicht in jedem Account. Ergebnis:MoonshotException - Not found the model. Immer das/v1/models-Endpoint des Anbieters mit dem eigenen Key abfragen, bevor man eine Modell-ID in Config schreibt. - Langsame Backends brauchen großzügige Timeouts. Der HTTP-Timeout für Agent→Gateway-Tool-Calls stand anfangs auf 30 Sekunden — zu knapp, sobald ein Tool selbst auf ein langsames Modell oder einen echten Rebuild wartet. Nach einem Vorfall im Mai 2026 (ein Bulk-Load lief gateway-seitig durch, der Agent sah aber nur einen leeren Timeout-Fehler) ist der Default jetzt 300 Sekunden und per ENV-Variable einstellbar. Details in der Tech-Vertiefung.
- Sticky-Override ist nicht persistent. Setzt du
/model X, ist nach Agent-RestartDEFAULT_MODELwieder aktiv. Designentscheidung, kein Bug. Wer's permanent will, ändert.env. - Keine automatische Eskalation. Wintermute entscheidet
nicht selbst, ob eine Antwort schwach war und mit
THINKING_MODELneu laufen sollte. Bewusste Wahl: User- Kontrolle statt versteckte Kosten. Zwei Eskalations- Strategien wurden inMODELS.mdskizziert (Self-Evaluation und Hedge-Keyword-Detection), aber nicht implementiert. Empfehlung dort: wenn überhaupt, dann als Vorschlag im Stil von soll ich das mit /think nochmal versuchen?, nicht als automatischer Re-Run. - Max-Token-Caps sind versteckt. In
agent/src/agent/config.pyhartcodiert. Default 4096, Thinking-Modelle 8192. Reicht für 99% der Fälle, aber wer einen 64K-Context-Output erwartet, muss in den Code. - Keine Failover-Logik. Wenn der Default-Provider gerade
ausfällt, gibt's keinen automatischen Fallback. LiteLLM
bietet das als Feature an (
fallbacks=[...]), Wintermute schaltet es derzeit nicht.
🔧 Tech-Vertiefung
LiteLLM normalisiert Tool-Calls (LESSONS §14)
Die wichtigste praktische Konsequenz von LiteLLM: alle
Tool-Call-Antworten kommen im OpenAI-Format, egal welcher
Anbieter im Hintergrund. Anthropic hat nativ content-Blöcke
mit type: "text" und type: "tool_use". Über LiteLLM kriegst
du stattdessen:
response = await litellm.acompletion(
model="anthropic/claude-opus-4-7",
messages=[...],
tools=tools_in_openai_format,
)
# Response-Shape (OpenAI):
response.choices[0].message.tool_calls
# → [ToolCall(id="call_abc",
# function=Function(name="...", arguments='{"x": 1}'))]
In Wintermute lebt der eigentliche LiteLLM-Aufruf in
agent/src/agent/core.py::_run_tool_loop. Verbatim aus dem
Referenz-Snapshot, gekürzt auf die Kern-Stellen:
# agent/src/agent/core.py (Auszug, Z.596-649)
async def _run_tool_loop(
self,
model: str,
messages: list[dict],
tools: list[dict],
extra_completion_kwargs: dict | None = None,
) -> str:
completion_kwargs: dict = {
"model": model,
"max_tokens": max_tokens_for(model),
"messages": messages,
}
if extra_completion_kwargs:
completion_kwargs.update(extra_completion_kwargs)
if tools:
completion_kwargs["tools"] = tools
completion_kwargs["tool_choice"] = "auto"
max_iterations = max(1, self.config.max_tool_iterations)
tool_iterations_used = 0
# …self-correction + duplicate-call counters elided, see Kap 07…
for _pass in range(absolute_pass_cap):
response = await litellm.acompletion(**completion_kwargs)
choice = response.choices[0]
assistant_msg = choice.message
tool_calls = getattr(assistant_msg, "tool_calls", None) or []
if not tool_calls:
reply_text = assistant_msg.content or ""
# …Validator + Finalisierung, siehe Kap 07…
return reply_text
# Tool-Calls ausführen, Ergebnisse zurück in messages
# appenden, Schleife wiederholt sich.
Vier Stellen, die in der Praxis wichtig sind:
max_tokens_for(model)ist kein statischer Wert.MAX_TOKENS_DEFAULT = 4096,MAX_TOKENS_THINKING = 8192inconfig.py— Reasoning-Modelle brauchen mehr Headroom, weil Hidden-Thinking-Tokens auf das Budget zählen.extra_completion_kwargsist der Durchreich-Kanal für Command-spezifische LiteLLM-Parameter — heute konkret:api_baseundapi_keyfür die/local-Route (siehe unten). Der Loop selbst bleibt Provider-agnostisch; er merged nur, was der Parser mitgibt.tool_choice="auto"ist der Standard. Wenn du das Modell zwingen willst, ein bestimmtes Tool zu rufen, geht es über{"type": "function", "function": {"name": "foo"}}. Wintermute nutzt das nicht — das Modell entscheidet selbst, wann es Tools braucht.absolute_pass_cap = max_iterations + self_correction_budget + 2. Belt-and-braces: das letzte +2 ist dafür, dass die Schleife garantiert terminiert, auch wenn beide Budget-Variablen fehlkonfiguriert sind. Diese Sorte Doppel-Cap erspart nächtliches Suchen nach Infinite-Loops.
Drei Stolperfallen aus LESSONS §14 (Tool-Format-Details)
1. Tool-Definitionen brauchen OpenAI-Envelope
Nicht Anthropics flaches {name, description, input_schema},
sondern das geschachtelte OpenAI-Format:
tools = [
{"type": "function",
"function": {
"name": "memory_search",
"description": "...",
"parameters": {...},
}},
]
Der Workshop zeigt das didaktisch destilliert; im Wintermute-Repo
lebt die Konvertierung in mcp-gateway/src/gateway/mcp_client.py,
das pro Tool eine to_openai_tool()-Methode hat — das Format-Mapping
ist eine einzige Stelle, nicht ein Wiederholungs-Pattern im Agent-Code.
2. arguments ist ein JSON-String, kein Dict
Defensiv parsen:
args = json.loads(tc.function.arguments or "{}")
In Wintermutes Tool-Loop steht das exakt so — inklusive des
or "{}"-Fallbacks für den Fall, dass ein Modell null oder
leerstring schickt. Ohne den Fallback bricht json.loads mit
JSONDecodeError, und das Modell sieht nie warum.
3. Tool-Ergebnisse gehen als role: "tool"-Message zurück
Nicht als Anthropic-tool_result-Block in einer User-Message:
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"name": tc.function.name,
"content": result_str,
})
LiteLLM übersetzt das wieder ins Anthropic-tool_result-Format
zurück, wenn der Anbieter das ist. Du als Anwendungs-Schreiber
bleibst im OpenAI-Schema, der Provider-Spezifika-Code ist in
LiteLLM eingekapselt.
Tool-Loop-Cap als Sicherheitsventil
LiteLLM bringt keinen eingebauten Cap auf Tool-Loop- Iterationen mit. Ohne Cap kann ein halluzinierendes Modell sich in eine Endlosschleife reden (Tool X aufrufen, Fehlschlag, Tool X erneut aufrufen, Fehlschlag, immer so weiter).
Wintermutes Cap: MAX_TOOL_ITERATIONS=25 (default). Bei Hit:
strukturierte weitermachen oder aufhören?-Frage an das
Modell statt hartem Abbruch. Mehr dazu in Kap 07.
Der Originalkommentar im Code erklärt das Dimensionieren des Caps:
# agent/src/agent/config.py (Auszug, Z.128-142)
# Hard ceiling on tool-use iterations per turn. The loop runs at
# most this many model passes that produce tool_calls before bailing
# out with a structured "should I continue?" message. Default 25 is
# sized for current realistic agentic workflows: a self-PR (read,
# branch, multi-file commit, open PR) sits at ~8–12 calls in the
# happy path; recall + memory_save round-trips around an answer
# add a few; cap at ~2x that gives headroom for retries without
# masking genuine runaway loops.
max_tool_iterations: int = 25
Der Cap von 25 ist also keine willkürliche Zahl, sondern gemessen an einem realistischen Worst-Case-Workflow (Self-PR). Der Faktor 2x gibt Headroom für eine Handvoll Retries.
Neben dem Iteration-Cap hat Wintermute noch zwei feiner granulare Defensiv-Mechanismen, die in derselben Datei dokumentiert sind:
duplicate_tool_call_limit: int = 2— schneidet identische Tool-Calls ((tool_name, sorted_args)) nach der dritten Wiederholung ab und gibt eine synthetische Framework-Note zurück. Bricht silent-retry-Loops auf strukturierte Tool-Fehler.agent_self_correction_attempts: int = 1— wenn der Post-Turn-Validator halluzinierte Identifier in einer Reply flagged, hat der Loop ein eigenes Budget für eine Self-Correction-Runde, das nicht gegenmax_tool_iterationszählt. Verhindert dass eine lange Tool-Kette die Selbstkorrektur aushungert.
Diese drei Schichten zusammen sind die strukturelle Antwort auf „Modell hängt fest“, ohne dem User eine harte „Agent-Crash“-Erfahrung zu liefern. Mehr in Kapitel 07.
HTTP-Timeout für Tool-Calls — von 30s auf 300s
Nicht direkt LiteLLM, aber dieselbe Timeout-Kette: der Agent
ruft Tools über den MCP-Gateway per HTTP auf, und dieser Client
hatte anfangs 30 Sekunden Timeout. Das war zu knapp — und der
Fehlermodus war fies. Der Originalkommentar in config.py
dokumentiert den Vorfall (Lesson 8, Variante 2):
# agent/src/agent/config.py (Auszug, Z.58-67)
# HTTP timeout for agent → gateway tool calls. Default 300s
# (5 minutes) is sized so embed-heavy tools like memory_load_doc
# against larger docs, and self_update which waits for a real
# rebuild, don't trip the client-side ReadTimeout while the gateway
# is still doing work. The previous 30s was too tight: see Lesson 8
# variant 2, Issue #35, and the 2026-05-06 11:10 incident where a
# 27-lesson bulk-load succeeded gateway-side but produced a literal
# `{"error": ""}` envelope at the agent because str(httpx.ReadTimeout)
# is the empty string.
agent_tool_http_timeout_seconds: float = 300.0
Zwei Lektionen daraus:
- Die Operation war erfolgreich, der Client sah einen Fehler. Der Gateway hat den Bulk-Load fertig verarbeitet — nur der Agent hatte längst aufgelegt. Split-Brain-Zustände dieser Art sind schwerer zu debuggen als klare Fehlschläge.
str(httpx.ReadTimeout)ist der leere String. Das Fehler-Envelope war deshalb ein wortloses{"error": ""}. Wenn du Exceptions in Fehler-Strings verpackst, nimmrepr(exc)oder den Klassennamen dazu.
Der Wert ist per AGENT_TOOL_HTTP_TIMEOUT_SECONDS in
docker-compose.yml durchgeschleift — tunable ohne Rebuild.
Relevanz für dieses Kapitel: sobald /local oder ein
ollama/*-Fast-Slot langsame Hardware anspricht, ist die
Timeout-Kette (Telegram → Agent → Gateway → Backend) das, was
zuerst reißt.
Modell-Identifier-Format und Anbieter-Discovery
LiteLLM erwartet <provider>/<model>-Notation. Beispiele:
| Identifier | Anbieter |
|---|---|
anthropic/claude-opus-4-7 |
Anthropic |
anthropic/claude-sonnet-4-6 |
Anthropic |
moonshot/kimi-k2.6 |
Moonshot (Thinking default-on, 256K ctx) |
moonshot/kimi-k2.5 |
Moonshot (Reasoning-Modell, älter) |
openai/gpt-4o |
OpenAI |
gemini/gemini-2.0-flash |
|
ollama/qwen3:32b |
Ollama-Inferenz-Server (lokal oder remote) |
LiteLLM detektiert den Anbieter aus dem Präfix und lädt den
passenden API-Key aus der Umgebung (ANTHROPIC_API_KEY,
MOONSHOT_API_KEY, OPENAI_API_KEY, …). Für ollama/* liest
LiteLLM zusätzlich OLLAMA_API_BASE (Server-URL) und optional
OLLAMA_API_KEY — die Standard-Variablen, über die Wintermute
den Fast-Slot auf ein Remote-Ollama legen kann
(FAST_MODEL=ollama/<model> plus OLLAMA_API_BASE in .env).
Discovery: was kann mein Account? Dokumentierte Modell-IDs sind nicht garantiert in jedem Account-Tier freigeschaltet. Vor dem Config-Eintrag immer prüfen (LESSONS §13):
# Moonshot-Beispiel
curl -H "Authorization: Bearer $MOONSHOT_API_KEY" \
https://api.moonshot.ai/v1/models
# OpenAI-Beispiel
curl -H "Authorization: Bearer $OPENAI_API_KEY" \
https://api.openai.com/v1/models
# Anthropic-Beispiel
curl -H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
https://api.anthropic.com/v1/models
Das ist die einzige verlässliche Quelle. Die Doku des Anbieters listet oft mehr Modelle, als dein Account tatsächlich rufen darf — typischerweise weil bestimmte Modell-Tiers Enterprise-Zugang brauchen oder erst nach einer Mindest-Nutzung freigeschaltet werden.
Einen neuen Anbieter hinzufügen — vier Schritte
Das Hinzufügen z.B. von OpenAI ist eine ENV-Variable plus eine
Compose-Zeile. Kein Code-Change in core.py oder im
Tool-Loop.
Schritt 1: ENV-Variable in .env
OPENAI_API_KEY=sk-...
Schritt 2: In docker-compose.yml durchschleifen
Unter services.agent.environment: eintragen — sonst sieht der
Container das nicht (siehe LESSONS §20):
services:
agent:
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
# …weitere Provider-Keys parallel…
Das ${VAR:-}-Pattern ist wichtig: ohne den :--Default scheitert
Compose mit "variable not set", wenn der Key in .env fehlt. Mit
:- wird ein leerer String durchgereicht, den der Agent in seiner
Settings-Validierung erkennen kann.
Schritt 3: DEFAULT_MODEL umstellen oder per /model switchen
DEFAULT_MODEL=openai/gpt-4o
Oder zur Laufzeit nur für diese Conversation:
/model openai/gpt-4o
Schritt 4 (Optional): in Settings als Pflicht-Variable deklarieren
Wintermute deklariert die wichtigsten Provider-Keys explizit
in agent/src/agent/config.py, damit Pydantic-Settings das
Fehlen bei Container-Start meldet und nicht erst beim ersten
Provider-Call:
# agent/src/agent/config.py (Auszug, Z.9-19)
class Settings(BaseSettings):
"""Wintermute agent configuration. All fields read from environment variables.
Models use LiteLLM's `<provider>/<model>` notation, e.g.
`anthropic/claude-opus-4-7`, `moonshot/kimi-k2-0711-preview`.
"""
# Provider API keys (LiteLLM also reads these from env directly,
# but we declare them here so missing keys fail fast at startup).
anthropic_api_key: str | None = None
moonshot_api_key: str | None = None
Fail-fast lohnt sich: ohne die explizite Deklaration kann der
Agent stundenlang nutzbar wirken und erst beim ersten Aufruf des
fehlkonfigurierten Modells ein 401 vom Provider werfen — mit
einem schwer zu debuggenden Stacktrace, weil der Fehler bei
litellm.acompletion() herauskommt, nicht bei der Container-
Initialisierung.
/local — Remote-Ollama per Alias
Seit Juni 2026 hat Wintermute einen vierten Routing-Pfad: ein
Ollama auf eigener Hardware, erreichbar über Tailscale. Die
Config dafür lebt in agent/src/agent/config.py:
# agent/src/agent/config.py (Auszug, Z.30-44)
# ----- Local Ollama (Tailscale or other remote OpenAI-compatible endpoint) -----
# Base URL for the user's local Ollama instance, e.g. a Tailscale peer.
# Used by /local <alias> <message> to route a turn to a local model.
# Leave empty to disable /local entirely.
# NOTE: this is the *user-facing* Ollama for chat models, NOT the
# wintermute-ollama container (which is for embeddings only — EMBEDDING_URL).
ollama_api_base: str = ""
# Optional bearer token for the remote Ollama (most self-hosted instances
# don't require one; Tailscale VPN provides the security layer).
ollama_api_key: str = ""
# Comma-separated alias=model mappings for /local, e.g.:
# qwen-code=ollama/qwen-coder-32k:latest,deepseek=ollama/deepseek-r1:32b
# Aliases are resolved to full LiteLLM model strings (<provider>/<model>).
local_models_raw: str = ""
Der NOTE-Kommentar ist die wichtigste Zeile: das Compose-File
enthält auch einen wintermute-ollama-Container — aber der ist
ausschließlich für Embeddings da (Kap 04, EMBEDDING_URL).
OLLAMA_API_BASE zeigt auf einen zweiten, benutzereigenen
Ollama-Server für Chat-Modelle. Die beiden zu verwechseln ist
der naheliegendste Setup-Fehler; die Compose-Kommentare warnen
explizit davor.
Der Parser-Zweig in _parse_command löst den Alias auf und
packt die Ollama-Verbindungsdaten als extra_completion_kwargs
in das CommandResult:
# agent/src/agent/core.py (Auszug, Z.359-406, gekürzt)
if command == "local":
local_models = self.config.local_models
if not rest:
# /local ohne Argumente: konfigurierte Aliase auflisten
# …gekürzt…
return CommandResult(reply="\n".join(lines))
parts = rest.split(None, 1)
alias = parts[0]
msg = parts[1] if len(parts) > 1 else ""
# …Usage-/Unknown-Alias-Replies gekürzt…
resolved_model = local_models[alias]
extra: dict = {}
if self.config.ollama_api_base:
extra["api_base"] = self.config.ollama_api_base
if self.config.ollama_api_key:
extra["api_key"] = self.config.ollama_api_key
return CommandResult(
message=msg,
one_shot_model=resolved_model,
extra_completion_kwargs=extra,
)
chat() reicht die Kwargs an _run_tool_loop weiter, der sie
in die completion_kwargs merged (siehe den Loop-Auszug oben).
Damit ist /local architektonisch nichts Neues — es ist ein
One-Shot-Override wie /think, nur mit zusätzlichem api_base.
Der Loop bleibt unangetastet.
Zwei Design-Details, die das Nachbauen wert sind:
/localist vonALLOWED_MODELSausgenommen — genau wie/thinkund/fast. Begründung aus dem Docstring von_is_model_allowed: die Allowlist beschränkt nur den Free-Form-Befehl/model <name>, wo der User beliebige IDs tippen kann.LOCAL_MODELSist selbst schon eine Operator-Allowlist — der User kann nur Aliase rufen, die der Operator konfiguriert hat, und ohne Konfiguration ist/localkomplett aus. Die lokalen Ollama-IDs zusätzlich durchALLOWED_MODELSzu schleusen würde/localbei gesetzter Allowlist immer brechen, ohne echte Einschränkung zu gewinnen.- Alias-Indirektion statt roher Modell-IDs im Chat. Der User
tippt
/local qwen …, nicht/local ollama/qwen-coder-32k:latest …. Die Map macht die Bedienung tippbar und gibt dem Operator die Kontrolle darüber, welche lokalen Modelle überhaupt erreichbar sind.
ENV-Aliase — ALLOWED_MODELS vs. ALLOWED_MODELS_RAW
Ein Detail, über das man beim Nachbauen mit Pydantic-Settings
stolpert: Pydantic mappt Feldnamen auf ENV-Variablen durch
simples Uppercasing. Das Feld allowed_models_raw liest also
ALLOWED_MODELS_RAW — aber dokumentiert und auf Deployments in
Benutzung sind die kürzeren Namen ALLOWED_MODELS und
LOCAL_MODELS. Wintermute löst das mit einem Alias-Hook in
model_post_init:
# agent/src/agent/config.py (Auszug, Z.201-224, gekürzt)
def model_post_init(self, __context: object) -> None:
"""Backward-compat: honour legacy AGENT_MODEL if DEFAULT_MODEL is unset."""
legacy = os.getenv("AGENT_MODEL")
if legacy and self.default_model == "anthropic/claude-opus-4-7" \
and not os.getenv("DEFAULT_MODEL"):
logger.warning(
"AGENT_MODEL is deprecated, use DEFAULT_MODEL. "
"Honouring legacy value: %s", legacy,
)
object.__setattr__(self, "default_model", legacy)
# The documented env var names (ALLOWED_MODELS, LOCAL_MODELS) are
# shorter and already in use on deployments, so we pick them up
# here as aliases when the _RAW variant is not set.
allowed = os.getenv("ALLOWED_MODELS")
if allowed is not None and not self.allowed_models_raw:
object.__setattr__(self, "allowed_models_raw", allowed)
local = os.getenv("LOCAL_MODELS")
if local is not None and not self.local_models_raw:
object.__setattr__(self, "local_models_raw", local)
Drei Dinge stecken hier drin:
- Dokumentierter Name gewinnt gegen internen Namen. Statt
alle Deployments auf
ALLOWED_MODELS_RAWumzustellen, liest der Hook die dokumentierten Kurzformen als Fallback. Die_RAW-Variante hat Vorrang, falls beide gesetzt sind. - Legacy-Kompatibilität mit Warnung. Das ältere
AGENT_MODELwird noch honoriert, aber mit Deprecation- Warning im Log — sanfte Migration statt stillem Bruch. object.__setattr__als Workaround. Pydantic-Settings lässt Mutation nach Init normalerweise nicht zu; der Griff am Modell vorbei ist bewusst und kommentiert. Nicht schön, aber auf drei Zeilen begrenzt.
Die geparsten Werte kommen dann über Properties raus:
Settings.allowed_models (Liste) und Settings.local_models
(Dict alias -> LiteLLM-Modell-String, tolerant gegenüber
kaputten Einträgen ohne =).
In docker-compose.yml sind alle vier Variablen der
Routing-Schicht durchgeschleift:
# docker-compose.yml (Auszug, services.agent.environment)
- DEFAULT_MODEL=${DEFAULT_MODEL:-anthropic/claude-opus-4-7}
- THINKING_MODEL=${THINKING_MODEL:-moonshot/kimi-k2.6}
- FAST_MODEL=${FAST_MODEL:-moonshot/kimi-k2.5}
- OLLAMA_API_BASE=${OLLAMA_API_BASE:-}
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
- ALLOWED_MODELS=${ALLOWED_MODELS:-}
- LOCAL_MODELS=${LOCAL_MODELS:-}
- AGENT_TOOL_HTTP_TIMEOUT_SECONDS=${AGENT_TOOL_HTTP_TIMEOUT_SECONDS:-300}
Sticky-Override-Implementierung im Detail
In agent/src/agent/core.py. Conversations sind über
conversation_id (z.B. Telegram-Chat-ID oder finn-Channel-ID)
identifiziert. Der Override-State ist eine simple Map als
Attribut der Agent-Instanz:
# agent/src/agent/core.py (Auszug, Z.153-155)
# Per-conversation sticky model overrides set via `/model <name>`.
# In-memory only; resets on agent restart (matches Wintermute's amnesia model).
self._sticky_models: dict[str, str] = {}
Der Parser für /model, /think, /fast und /local ist eine
eigene Methode — die volle Form, weil die Verwaltungs-Subkommandos
(list, reset, leerer Befehl als Status-Anzeige) den meisten
Platz brauchen:
# agent/src/agent/core.py (Auszug, Z.323-455 stark gekürzt)
def _parse_command(
self, message: str, conversation_id: str
) -> CommandResult:
"""Parse leading slash-commands and return the resolved CommandResult.
Recognised commands (must be the first token):
/think <msg> one-shot: use THINKING_MODEL for this turn
/fast <msg> one-shot: use FAST_MODEL for this turn
/local <alias> <msg> one-shot: use a local Ollama model by alias
/local list configured local model aliases
/model show current sticky model + aliases
/model <name> sticky: use <name> for all future turns in this conv
/model reset clear sticky override for this conv
/model list list known model aliases
"""
match = _COMMAND_RE.match(message.strip())
if not match:
return CommandResult(message=message)
command, rest = match.group(1), (match.group(2) or "").strip()
if command == "think":
if not rest:
return CommandResult(reply="Usage: `/think <your message>`")
return CommandResult(message=rest, one_shot_model=self.config.thinking_model)
if command == "fast":
if not rest:
return CommandResult(reply="Usage: `/fast <your message>`")
return CommandResult(message=rest, one_shot_model=self.config.fast_model)
if command == "local":
# …Alias-Auflösung, siehe eigener Abschnitt oben…
# /model … sticky-Pfad
sticky = self._sticky_models.get(conversation_id)
active = sticky or self.config.default_model
if rest == "reset":
had = self._sticky_models.pop(conversation_id, None)
if had:
return CommandResult(
reply=f"Sticky model cleared. Back to `{self.config.default_model}`."
)
return CommandResult(
reply=f"No sticky model was set. Using `{self.config.default_model}`."
)
# /model <name>
new_model = rest
if not self._is_model_allowed(new_model):
return CommandResult(reply=(
f"`{new_model}` is not in ALLOWED_MODELS. "
f"Set ALLOWED_MODELS empty to allow any model, "
f"or add it to the allowlist."
))
self._sticky_models[conversation_id] = new_model
return CommandResult(
reply=f"Sticky model set: `{new_model}`. (Use `/model reset` to revert.)"
)
Der eigentliche Modell-Resolver lebt in chat() selbst (nicht
in einer eigenen Methode — die Vier-Quellen-Priorität ist
linear genug):
# agent/src/agent/core.py (Auszug, Z.502-508)
sticky_model = self._sticky_models.get(conversation_id)
active_model = (
model
or cmd.one_shot_model
or sticky_model
or self.config.default_model
)
Die Reihenfolge ist explizit:
- Ein Caller, der das Modell hart vorgibt (typisch im Test), wins always.
- Ein One-Shot-Slash-Command (
/think,/fast,/local) gilt nur für diesen Turn. - Ein Sticky-Override für die Conversation gilt bis zum
/model resetoder Restart. - Der globale Default kommt aus
Settings.default_model.
Die Map ist in-memory — Restart leert alles. Das ist die
Wintermute-Philosophie: Conversation-State ist ephemer,
Langzeit-State (Memory, Kap 04) ist persistent. Wer dauerhaft
ein anderes Modell will, ändert DEFAULT_MODEL in .env.
Provider-Quirk — Moonshots kimi-k2.6 und Thinking-Modus
Moonshots Flaggschiff kimi-k2.6 unterstützt Thinking und
Non-Thinking auf derselben Modell-ID. Steuerung über
Request-Parameter:
response = await litellm.acompletion(
model="moonshot/kimi-k2.6",
messages=[...],
extra_body={"thinking": {"type": "disabled"}}, # Thinking aus
)
Wintermute leitet das derzeit nicht durch — /think und
/fast schalten stattdessen zwischen verschiedenen
Modell-IDs (Default/Thinking/Fast-Slots). Das ist im
Referenz-Snapshot bewusst so dokumentiert (MODELS.md):
Both
/thinkand/fastsimply pick a different model name. A future enhancement could add aTHINKING_DISABLED_MODELSenv list, and in_run_tool_loop, when the active model has the disabled tag, passextra_body={"thinking": {"type": "disabled"}}to LiteLLM. This would let you have a single Kimi model serve both "think hard" and "answer fast" with explicit per-mode latency/cost. It was deferred from the initial implementation to keep the model selection logic simple.
Die zugrundeliegende Lektion ist die generelle Form jeder
Provider-Quirk-Diskussion: was im LiteLLM-Standard nicht
unterstützt wird, geht trotzdem — über extra_body. Damit
kannst du Provider-spezifische Felder durchschleifen, ohne auf
LiteLLM-Support warten zu müssen. Trade-off: dein Code wird
Provider-aware an genau dieser Stelle, was das Provider-Switching
für genau diese Feature wieder bricht.
Mit extra_completion_kwargs existiert inzwischen sogar der
saubere Transportweg dafür im Tool-Loop — /local nutzt ihn für
api_base. Trotzdem gilt: lieber den ganzen Anbieter-Wechsel
über einen anderen Modell-Slot machen (Wintermutes Wahl) als per
extra_body Provider-Quirks ins Hot-Path-Code einbauen.
Empfehlungen für eigene Implementierungen
- LiteLLM (oder Äquivalent) von Tag 1. Direkt-SDKs pro Anbieter sind eine Falle. Auch wenn du heute nur Claude benutzt, willst du in sechs Monaten umschalten können.
- Drei Slots reichen. Default + Thinking + Fast deckt 90% der Routing-Bedürfnisse. Lokale Modelle als Alias-Map dazuhängen (wie
/local), statt für jedes Modell einen neuen Slot zu erfinden. - Slash-Commands für Modell-Wahl, nicht Auto-Routing. Auto-Eskalation ist verlockend, aber teuer und unsicher. User kennt seinen Anwendungsfall.
/v1/modelsvor Config-Eintrag. Immer mit dem eigenen Key prüfen, was der Anbieter tatsächlich freigeschaltet hat (LESSONS §13).- Tool-Loop-Cap pflichtig. Ohne wird's irgendwann teuer.
- Conversation-State in-memory halten. Sticky Override persistent zu machen führt zu Warum redet der Agent jetzt plötzlich anders?-Bugs nach Restart.
- Allowlist im öffentlichen Setup. Wenn andere Personen den Agenten erreichen können,
ALLOWED_MODELSsetzen. Verhindert Kostenexplosionen durch Tippfehler oder Mutwilligkeit. Operator-kuratierte Pfade (/think,/fast,/local) brauchen die Allowlist nicht — sie sind schon durch die Config beschränkt. - Timeouts an die langsamste Route anpassen. Sobald lokale oder Remote-Modelle im Spiel sind, reichen Web-übliche 30s nicht mehr. Wintermutes Default: 300s, per ENV tunable — und im Fehler-String niemals nur
str(exc)loggen (Timeout-Exceptions können leer stringifizieren).
📚 Quellen im Wintermute-Repo
MODELS.md— Modell-Slot-Architektur, Slash-Commands, Allowlist, Auto-Escalation-Designentscheidungen (Stand 47ec498 noch ohne/local-Abschnitt — die/local-Doku lebt in Code- und Compose-Kommentaren)agent/src/agent/core.py—_run_tool_loop, Slash-Command-Parsing (inkl./local), Sticky-Override-Resolver,CommandResult.extra_completion_kwargsagent/src/agent/config.py— Settings, Max-Token-Caps, Modell-Slot-ENV-Vars,OLLAMA_API_BASE/LOCAL_MODELS, ENV-Alias-Hook (model_post_init),agent_tool_http_timeout_secondsdocker-compose.yml— ENV-Durchschleifung, Abgrenzung Embeddings-Ollama vs. Chat-Ollama (Kommentare amagent-Service)LESSONS.md§8 (Timeout-Kette, Variante 2), §13 (Provider-Modell-IDs prüfen), §14 (LiteLLM normalisiert auf OpenAI-tool_calls)- Repo-PRs #53 (Ollama-ENV-Verdrahtung), #56 (Timeout 30s→300s),
#58 (
/local-Command), #59 (ENV-Aliase) — für die Detail-Geschichte hinter den Änderungen seit dem letzten Snapshot - LiteLLM-Doku