Werkstatt · Kapitel 6 · 17. August 2026

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 /local fü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 gibt tool_calls-Arrays. Moonshots Kimi-Modelle haben einen extra_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

  1. 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 genauso litellm.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 OpenAIs tool_calls), und Provider-spezifische Eigenheiten. Dein Code spricht ein Interface, egal welcher Anbieter dahinter steht.
  2. Drei Modell-Slots. Statt das Modell hardzucoden, hat Wintermute drei konfigurierbare Slots: DEFAULT_MODEL ist das Alltagsmodell (Default anthropic/claude-opus-4-7), THINKING_MODEL wird von /think <message> für einen Turn aktiviert (Reasoning-Modell, langsamer und teurer, Default moonshot/kimi-k2.6), FAST_MODEL von /fast <message> (billig und schnell, Default moonshot/kimi-k2.5).
  3. Sticky Override pro Conversation. Mit /model <name> schaltest du das Default für diese Conversation um, bis du /model reset machst oder der Agent neu startet. In-memory, nicht persistent — passt zur Wintermute-Philosophie: Konversations-Zustand ist flüchtig, nur Langzeit-Memory persistiert.
  4. 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 in LOCAL_MODELS eine Alias-Map (qwen=ollama/qwen3:32b,...); OLLAMA_API_BASE zeigt auf den Remote-Server. /local ohne 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-4o in .env setzen, durch docker-compose.yml durchschleifen, 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, /fast und /local sind bewusst ausgenommen: sie können nur Modelle wählen, die der Operator ohnehin vorkuratiert hat (Slot-ENV-Vars bzw. die LOCAL_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.6 hat einen extra_body={"thinking": {...}}-Parameter, den Wintermute aktuell nicht durchschleift (siehe MODELS.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-thinking ist 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-Restart DEFAULT_MODEL wieder 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_MODEL neu laufen sollte. Bewusste Wahl: User- Kontrolle statt versteckte Kosten. Zwei Eskalations- Strategien wurden in MODELS.md skizziert (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.py hartcodiert. 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 = 8192 in config.py — Reasoning-Modelle brauchen mehr Headroom, weil Hidden-Thinking-Tokens auf das Budget zählen.
  • extra_completion_kwargs ist der Durchreich-Kanal für Command-spezifische LiteLLM-Parameter — heute konkret: api_base und api_key fü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 gegen max_tool_iterations zä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, nimm repr(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 Google
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:

  • /local ist von ALLOWED_MODELS ausgenommen — genau wie /think und /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_MODELS ist selbst schon eine Operator-Allowlist — der User kann nur Aliase rufen, die der Operator konfiguriert hat, und ohne Konfiguration ist /local komplett aus. Die lokalen Ollama-IDs zusätzlich durch ALLOWED_MODELS zu schleusen würde /local bei 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_RAW umzustellen, 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_MODEL wird 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:

  1. Ein Caller, der das Modell hart vorgibt (typisch im Test), wins always.
  2. Ein One-Shot-Slash-Command (/think, /fast, /local) gilt nur für diesen Turn.
  3. Ein Sticky-Override für die Conversation gilt bis zum /model reset oder Restart.
  4. 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 /think and /fast simply pick a different model name. A future enhancement could add a THINKING_DISABLED_MODELS env list, and in _run_tool_loop, when the active model has the disabled tag, pass extra_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

  1. 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.
  2. 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.
  3. Slash-Commands für Modell-Wahl, nicht Auto-Routing. Auto-Eskalation ist verlockend, aber teuer und unsicher. User kennt seinen Anwendungsfall.
  4. /v1/models vor Config-Eintrag. Immer mit dem eigenen Key prüfen, was der Anbieter tatsächlich freigeschaltet hat (LESSONS §13).
  5. Tool-Loop-Cap pflichtig. Ohne wird's irgendwann teuer.
  6. Conversation-State in-memory halten. Sticky Override persistent zu machen führt zu Warum redet der Agent jetzt plötzlich anders?-Bugs nach Restart.
  7. Allowlist im öffentlichen Setup. Wenn andere Personen den Agenten erreichen können, ALLOWED_MODELS setzen. Verhindert Kostenexplosionen durch Tippfehler oder Mutwilligkeit. Operator-kuratierte Pfade (/think, /fast, /local) brauchen die Allowlist nicht — sie sind schon durch die Config beschränkt.
  8. 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_kwargs
  • agent/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_seconds
  • docker-compose.yml — ENV-Durchschleifung, Abgrenzung Embeddings-Ollama vs. Chat-Ollama (Kommentare am agent-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