feat: Modell pro Chat waehlen — und sehen, welches gerade laeuft

Nach einem Sicherheitsfilter-Treffer fuhr ein Projekt still auf dem
Ausweich-Modell weiter, und nirgends war zu sehen, auf welchem. Zurueck ging
nur ueber "Liste leeren" im Filter-Panel, und das gleich fuer alle Chats.

Jetzt drei Ebenen (aria-brain/context_model.py):
  Einstellung (brainModel) < pro Chat < Ausweich (Sicherheitsfilter)

- Pro Chat: Hauptchat und jedes Projekt koennen ein eigenes Modell haben, in
  /shared/config/context_models.json. Bewusst kein Feld im Projekt-Manifest —
  der Hauptchat ist kein Projekt und waere sonst aussen vor.
- "↩ zurueck" pro Chat: loescht nur die Filter-Vermerke DIESES Chats. Blockt
  der Filter erneut, weicht das Brain eben wieder aus.
- Angezeigt wird, was wirklich laeuft: chat() gibt das Modell aus modelUsage
  der CLI zurueck (aufgeloeste Version statt Alias) samt Grund; Brain → Bridge
  → chat_backup + Live-Payload → App und Diagnostic. Als Grundeinstellung
  zaehlt das Modell, mit dem das Brain laeuft, nicht der Dateiwert — so faellt
  ein ausstehender Neustart nach brainModel-Aenderung auf.

Diagnostic: 🧠-Auswahl neben "+ Projekt" fuer den fokussierten Chat, Modell im
Untertitel jeder Kontext-Karte, Modell statt "Claude" am Bubble-Badge (orange
mit ↪ bei Ausweich).

App: hinter der Quell-Anzeige (Einstellungen, Default aus — Kopfzeile bleibt
fuer Mama unveraendert) ein Modell-Chip in der Kopfzeile; Tippen oeffnet die
Auswahl fuer diesen Chat inkl. "Zurueck". Laeuft ueber den vorhandenen
brain_request-Kanal, also keine neuen RVS-Typen.

Tests: 47/47 (12 neue fuer die Aufloesungsreihenfolge gegen agent.chat()),
tsc ohne Fehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:39:30 +02:00
co-authored by Claude Opus 5.5
parent d221c1a320
commit 6d978d72b3
9 changed files with 596 additions and 19 deletions
+33 -6
View File
@@ -39,9 +39,13 @@ import watcher as watcher_mod
import oauth as oauth_mod
import projects as projects_mod
import safeguard
import context_model
import project_vms as project_vms_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
_NO_MODEL = {"model": "", "requested": "", "reason": ""}
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -2529,7 +2533,7 @@ class Agent:
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
return reply, "wake-off", False, False, False
return reply, "wake-off", False, False, False, _NO_MODEL
# Wake-Word AN per Befehl (Text/Aufnahme-Button): App startet den Listener.
if _user_wants_wake_on(user_message):
@@ -2538,7 +2542,7 @@ class Agent:
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-on] Befehl erkannt — App startet Listener")
return reply, "wake-on", False, False, False
return reply, "wake-on", False, False, False, _NO_MODEL
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
@@ -2565,7 +2569,7 @@ class Agent:
elif _wants_continue:
converse = True
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False
return fast_reply, "fast-path", speak, converse, False, _NO_MODEL
# 1. User-Turn an die Konversation. Den Zeitstempel behalten wir: er
# identifiziert diesen Turn, wenn Anthropics Sicherheitsfilter
@@ -2591,7 +2595,7 @@ class Agent:
elif _wants_continue:
converse = True
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False
return local_reply, "local", speak, converse, False, _NO_MODEL
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -2733,14 +2737,20 @@ class Agent:
# Filter gescheitert, nehmen wir direkt das Ausweich-Modell — sonst
# laeuft jeder Turn erst gegen dieselbe Wand. Rollt der Turn aus dem
# Fenster, gilt wieder das Hauptmodell; das verfaellt von selbst.
primary_model = self.proxy.model
# Reihenfolge: Einstellung (brainModel) < pro Chat < Ausweich.
primary_model, model_reason = context_model.resolve(
active_project_id, self.proxy.model)
turn_model = primary_model
_blocked = safeguard.blocked_models(active_project_id,
[t.ts for t in window])
if safeguard.norm_model(primary_model) in _blocked:
turn_model = safeguard.fallback_for(primary_model)
model_reason = context_model.REASON_FALLBACK
logger.warning("[safeguard] %s ist in diesem Fenster blockiert — "
"Turn laeuft direkt auf %s", primary_model, turn_model)
if model_reason == context_model.REASON_PER_CHAT:
logger.info("[context-model] %s nutzt %s (pro Chat gesetzt)",
active_project_id or "(Hauptchat)", turn_model)
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
len(hot), len(cold), len(active_skills), len(all_skills),
@@ -2753,6 +2763,9 @@ class Agent:
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann.
final_reply = ""
# Welches Modell hat am Ende geantwortet — fuer die Anzeige in App und
# Diagnostic. Default: das angefragte; die CLI darf es praezisieren.
answered_model = turn_model
# Vorlesen ja/nein fuer diesen Claude-Turn. Default True (Gespraech).
# Fuehrt Claude einen Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# aus, wird die Antwort NICHT vorgelesen — der Text landet aber normal in
@@ -2763,8 +2776,16 @@ class Agent:
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
try:
for iteration in range(self.MAX_TOOL_ITERATIONS):
_model_before = turn_model
result, turn_model = self._llm_call(
messages, tools, active_project_id, turn_model, user_turn_ts)
if turn_model != _model_before:
# _llm_call ist auf das Ausweich-Modell gewechselt.
model_reason = context_model.REASON_FALLBACK
# modelUsage der CLI ist die Wahrheit: bei einem Tier-Alias
# steht hier die aufgeloeste Version ("sonnet" → "claude-...").
if result.model_used:
answered_model = result.model_used
if result.tool_calls:
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
messages.append(ProxyMessage(
@@ -2915,7 +2936,13 @@ class Agent:
elif self._ear_control == "on":
answered_by, speak, converse = "wake-on", False, False
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, answered_by, speak, converse, awaiting_reply)
# Modell-Info: was hat geantwortet und warum genau dieses. Die
# Oberflaechen zeigen das an — sonst merkt niemand, dass ein Projekt
# gerade auf dem Ausweich-Modell faehrt.
model_info = {"model": answered_model or turn_model,
"requested": turn_model,
"reason": model_reason}
return (final_reply, answered_by, speak, converse, awaiting_reply, model_info)
# ── Tool-Dispatcher ───────────────────────────────────────
+95
View File
@@ -0,0 +1,95 @@
"""Welches Modell gilt in welchem Chat.
Drei Ebenen, von unten nach oben:
1. Einstellung — `brainModel` aus runtime.json (Diagnostic → Sprachmodell).
Gilt fuer alles, was keine eigene Wahl hat.
2. Pro Chat — ein Override je Kontext ("" = Hauptchat, sonst project_id).
Damit kann ein Projekt bewusst auf einem anderen Modell laufen, ohne das
Hauptmodell fuer alles umzustellen.
3. Ausweich — Anthropics Sicherheitsfilter hat das gewaehlte Modell an einem
Turn blockiert, der noch im Fenster liegt (safeguard.py). Das sticht alles,
ist aber nur so lange aktiv, wie der Turn im Fenster steht.
Warum ueberhaupt ein eigener Speicher und kein Feld im Projekt-Manifest: der
Hauptchat IST kein Projekt. Ein Manifest-Feld haette ihn aussen vor gelassen,
und genau dort faellt eine Modellwahl am ehesten an.
"""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Tuple
logger = logging.getLogger(__name__)
STORE_FILE = Path(os.environ.get("CONTEXT_MODELS_FILE",
"/shared/config/context_models.json"))
# Begruendungen, wie sie die Oberflaechen anzeigen. Bewusst kurze Schluessel —
# App und Diagnostic uebersetzen sie selbst, damit die Texte dort hingehoeren,
# wo sie gelesen werden.
REASON_SETTING = "einstellung"
REASON_PER_CHAT = "pro-chat"
REASON_FALLBACK = "ausweich"
_lock = threading.Lock()
def _load() -> dict:
try:
if STORE_FILE.exists():
data = json.loads(STORE_FILE.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
# Nur String→String; alles andere waere ein Schreibfehler von
# aussen und wuerde sonst still als Modellname durchgehen.
return {str(k): str(v) for k, v in data.items() if isinstance(v, str)}
except Exception as exc:
logger.warning("[context-model] lesen fehlgeschlagen: %s", exc)
return {}
def _save(data: dict) -> None:
try:
STORE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STORE_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(STORE_FILE)
except Exception as exc:
logger.warning("[context-model] schreiben fehlgeschlagen: %s", exc)
def all_overrides() -> dict:
return _load()
def get(context_id: str) -> str:
"""Der Override dieses Kontexts, oder "" wenn keiner gesetzt ist."""
return _load().get(context_id or "", "")
def set(context_id: str, model: str) -> str:
"""Override setzen. Leerer Modellname loescht ihn (= wieder Einstellung)."""
cid = context_id or ""
m = (model or "").strip()
with _lock:
data = _load()
if m:
data[cid] = m
else:
data.pop(cid, None)
_save(data)
logger.info("[context-model] %s → %s", cid or "(Hauptchat)", m or "(Einstellung)")
return m
def resolve(context_id: str, default_model: str) -> Tuple[str, str]:
"""(Modell, Begruendung) ohne Sicherheitsfilter — den kennt nur der Agent."""
ov = get(context_id)
if ov:
return ov, REASON_PER_CHAT
return default_model, REASON_SETTING
+115 -1
View File
@@ -39,6 +39,7 @@ import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import safeguard as safeguard_mod
import context_model as context_model_mod
import projects as projects_mod
import project_vms as project_vms_mod
@@ -680,6 +681,11 @@ class ChatOut(BaseModel):
# Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude",
# "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic.
answered_by: str = "claude"
# Welches Claude-Modell geantwortet hat (aus modelUsage der CLI, also die
# aufgeloeste Version) und WARUM dieses: "einstellung" | "pro-chat" |
# "ausweich" (Sicherheitsfilter). Leer, wenn kein Claude-Modell lief.
model: str = ""
model_reason: str = ""
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True
@@ -778,7 +784,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
reply, answered_by, speak, converse, awaiting_reply, model_info = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
@@ -801,6 +807,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
events=a.pop_events(),
project_id=pid,
answered_by=answered_by,
model=(model_info or {}).get("model", ""),
model_reason=(model_info or {}).get("reason", ""),
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
@@ -1265,6 +1273,112 @@ def safeguard_clear(body: SafeguardClearBody):
return {"ok": True, "removed": removed}
# ─── Modell pro Chat ────────────────────────────────────────────────
# Einstellung (brainModel) < pro Chat < Ausweich (Sicherheitsfilter). Siehe
# context_model.py. Diese Endpunkte liefern fuer jede Oberflaeche dieselbe
# Sicht: was ist eingestellt, was laeuft WIRKLICH, und warum.
_MODEL_CHOICES_CACHE: dict = {"at": 0.0, "data": []}
def _model_choices() -> list:
"""Modell-Auswahl fuer die Pickers — dieselbe Liste wie in der Diagnostic
(Proxy /v1/models, aus Anthropics Katalog). 10 Minuten im Speicher, denn
die App fragt bei jedem Oeffnen des Pickers. Fail-soft: ohne Proxy eine
leere Liste, die Anzeige des aktiven Modells funktioniert trotzdem."""
import time as _t
import httpx as _httpx
now = _t.time()
if _MODEL_CHOICES_CACHE["data"] and now - _MODEL_CHOICES_CACHE["at"] < 600:
return _MODEL_CHOICES_CACHE["data"]
try:
base = os.environ.get("PROXY_URL", "http://proxy:3456").rstrip("/")
r = _httpx.get(f"{base}/v1/models", timeout=8)
data = [{
"id": m.get("id", ""),
"name": m.get("display_name") or m.get("id", ""),
"section": m.get("section") or "tier",
} for m in (r.json().get("data") or []) if m.get("id")]
if data:
_MODEL_CHOICES_CACHE.update(at=now, data=data)
return data
except Exception as exc:
logger.warning("[context-model] Modell-Liste vom Proxy nicht ladbar: %s", exc)
return _MODEL_CHOICES_CACHE["data"]
def _context_model_rows() -> dict:
a = agent()
conv = conversation()
# Das Modell, mit dem das Brain gerade wirklich laeuft — NICHT neu aus
# runtime.json gelesen. brainModel greift erst nach Neustart; zeigten wir
# hier den Dateiwert, saehe ein ausstehender Neustart aus wie erledigt.
default = a.proxy.model
contexts = [("", "Hauptchat")] + [
(p["id"], p.get("name") or p["id"])
for p in projects_mod.list_projects()
]
rows = []
for cid, name in contexts:
override = context_model_mod.get(cid)
model, reason = context_model_mod.resolve(cid, default)
window = safeguard_mod.filter_window(conv.window(project_id=cid))
blocked = safeguard_mod.blocked_models(cid, [t.ts for t in window])
fallback = ""
if safeguard_mod.norm_model(model) in blocked:
fallback = safeguard_mod.fallback_for(model)
reason = context_model_mod.REASON_FALLBACK
rows.append({
"context_id": cid,
"name": name,
"override": override, # "" = folgt der Einstellung
"selected": model, # was gewaehlt ist (Einstellung/pro Chat)
"effective": fallback or model, # was der naechste Turn nutzt
"reason": reason,
"blocked": sorted(blocked),
})
choices = _model_choices()
names = {c["id"]: c["name"] for c in choices}
return {"default": default, "rows": rows, "choices": choices, "names": names}
@app.get("/context-model/list")
def context_model_list():
return _context_model_rows()
class ContextModelBody(BaseModel):
context_id: str = ""
model: str = "" # leer = Override loeschen, Einstellung gilt wieder
@app.post("/context-model/set")
def context_model_set(body: ContextModelBody):
cid = (body.context_id or "").strip()
if cid and not projects_mod.get_project(cid):
raise HTTPException(404, f"Projekt {cid} nicht gefunden")
context_model_mod.set(cid, body.model)
return _context_model_rows()
class ContextModelResetBody(BaseModel):
context_id: str = ""
@app.post("/context-model/back-to-selected")
def context_model_back(body: ContextModelResetBody):
"""Ausweich beenden: Sicherheitsfilter-Vermerke dieses Chats loeschen.
Danach laeuft der naechste Turn wieder auf dem gewaehlten Modell. Steht
der ausloesende Text noch im Verlauf, kann der Filter erneut greifen —
dann weicht das Brain eben wieder aus. Das ist gewollt: zurueck soll
jederzeit gehen, ohne dass man vorher irgendwas aufraeumen muss."""
removed = safeguard_mod.clear((body.context_id or "").strip())
out = _context_model_rows()
out["removed"] = removed
return out
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
@app.get("/metrics/calls")