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
+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")