Ollama als lokaler Anbieter + gewähltes LLM dauerhaft sichtbar

- OllamaKategorisierer (lokaler OpenAI-kompatibler Endpoint, kein Key/Netz),
  baue_kategorisierer('ollama'), Default-Ollama-Modell.
- modelle.lade_ollama_modelle: /api/tags + /api/show (Tool-Fähigkeit), nur
  tool-fähige taugen; leere Liste wenn Ollama aus.
- web: /api/ollama-modelle, Anbieter im Kategorisier-Flow + Cache-Key,
  Modell+Anbieter im Ergebnis (als_struktur).
- UI: Anbieter-Umschalter (OpenRouter/Ollama), gewähltes Modell als Chip im
  Konfig-Kopf (auch zugeklappt) + 'kategorisiert mit … (anbieter)' im Ergebnis,
  bookmarkbarer ?modell/?anbieter/?auto-Start.
- content-JSON-Fallback fürs Tool-Parsing (manche lokale Modelle liefern die
  Antwort als Text-JSON). +6 Tests (53 gesamt).

Ehrlich: lokal installierte Modelle (qwen2.5-coder/gemma4/qwen3.5) liefern kein
brauchbares Tool-Calling -> Ergebnis dort 'Sonstiges/unsicher' (ehrlich markiert,
nicht geraten). Cloud-Default deepseek-v4-flash voll verifiziert (1903 Angebote,
modellstabil).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeuner 2026-06-03 14:54:32 +02:00
parent 59d7d916ef
commit 2e80f0d826
8 changed files with 335 additions and 32 deletions

View file

@ -140,6 +140,45 @@ def _user_text(posten: list[dict]) -> str:
)
def _finde_zuordnungen(obj):
"""Sucht rekursiv ein 'zuordnungen'-Array in einem geparsten JSON-Objekt."""
if isinstance(obj, dict):
if isinstance(obj.get("zuordnungen"), list):
return obj["zuordnungen"]
for v in obj.values():
treffer = _finde_zuordnungen(v)
if treffer:
return treffer
elif isinstance(obj, list):
for v in obj:
treffer = _finde_zuordnungen(v)
if treffer:
return treffer
return None
def _zuordnungen_aus_content(content) -> list | None:
"""Extrahiert ein 'zuordnungen'-Array aus einem content-String (Fallback).
Manche lokale Modelle verpacken die Tool-Antwort als (ggf. in ```json
eingefasstes) JSON im Text statt als echte tool_calls. Wir lesen das defensiv
aus -- finden wir nichts Verwertbares, geben wir None zurück (kein Raten).
"""
if not isinstance(content, str) or "{" not in content:
return None
import re
for roh in re.findall(r"\{.*\}", content, re.DOTALL):
try:
obj = json.loads(roh)
except json.JSONDecodeError:
continue
treffer = _finde_zuordnungen(obj)
if treffer:
return treffer
return None
def _wartezeit(antwort, versuch: int, basis: float) -> float:
"""Backoff: Retry-After respektieren, sonst exponentiell ab max(basis, 2s)."""
try:
@ -319,23 +358,58 @@ class OpenRouterKategorisierer:
vorschlag="Key/Modell/Guthaben prüfen oder --no-llm verwenden",
)
try:
aufrufe = daten["choices"][0]["message"]["tool_calls"]
args = json.loads(aufrufe[0]["function"]["arguments"])
return list(args.get("zuordnungen", []))
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
# Modell hat das Tool nicht (sauber) aufgerufen -> leer; die
# kategorisiere()-Logik markiert die Posten dann als unsicher.
nachricht = daten["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
return []
# 1) Sauberer Tool-Call (OpenRouter / gute Modelle).
try:
args = json.loads(nachricht["tool_calls"][0]["function"]["arguments"])
z = args.get("zuordnungen")
if z is not None:
return list(z)
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
pass
# 2) Fallback: manche (lokale Ollama-) Modelle schreiben die Tool-Antwort
# als content-JSON statt als tool_calls. Defensiv auslesen, nicht raten.
z = _zuordnungen_aus_content(nachricht.get("content"))
return z if z else []
class OllamaKategorisierer(OpenRouterKategorisierer):
"""Lokaler LLM über Ollama (OpenAI-kompatibler Endpoint /v1/chat/completions).
Erbt die komplette Tool-Calling-Logik vom OpenRouter-Kategorisierer -- Ollama
spricht dasselbe OpenAI-Schema. Kein Key (der Dummy-Bearer wird ignoriert),
kein Netz, keine Drosselung. Nur tool-fähige lokale Modelle (z. B. qwen3.5,
qwen2.5-coder, gemma4) eignen sich; andere liefern keine sauberen tool_calls.
"""
def __init__(
self,
*,
modell: str,
base_url: str = "http://localhost:11434/v1",
session=None,
) -> None:
super().__init__(
api_key="ollama", # Dummy -- der lokale Server ignoriert den Bearer.
modell=modell,
base_url=base_url,
session=session,
mindest_abstand_s=0.0, # lokal: keine Rate-Limits
)
# Default je Anbieter. Für OpenRouter ein günstiges, aber verlässliches paid-
# Modell: deepseek-v4-flash macht sauberes Tool-Calling, ist über mehrere
# Batches konsistent und kostet nur ~1-2 Cent pro vollem Lauf (1903 Angebote).
# Free-Modelle sind bei OpenRouter oft hart gedrosselt; mit `--modell` (CLI) bzw.
# der Modellauswahl in der Web-UI lässt sich jederzeit ein anderes wählen.
# Free-Modelle sind bei OpenRouter oft hart gedrosselt. Für Ollama ein gängiges
# tool-fähiges lokales Modell als Fallback. Mit `--modell` (CLI) bzw. der
# Modellauswahl in der Web-UI lässt sich jederzeit ein anderes wählen.
_DEFAULT_MODELLE = {
"anthropic": "claude-sonnet-4-6",
"openrouter": "deepseek/deepseek-v4-flash",
"ollama": "qwen3.5:latest",
}
@ -350,6 +424,8 @@ def baue_kategorisierer(
modell = modell or _DEFAULT_MODELLE.get(anbieter)
if anbieter == "openrouter":
return OpenRouterKategorisierer(modell=modell, api_key=api_key)
if anbieter == "ollama":
return OllamaKategorisierer(modell=modell)
if anbieter == "anthropic":
return AnthropicKategorisierer(modell=modell)
raise AbbruchFehler(

View file

@ -113,3 +113,55 @@ def suche(
if nur_tools:
res = [m for m in res if m.tools]
return sorted(res, key=_rang)
# --- Ollama (lokale LLMs) ----------------------------------------------------
OLLAMA_BASIS = "http://localhost:11434"
def _ollama_kann_tools(sess, name: str) -> bool:
"""Liest die Capabilities eines lokalen Modells (/api/show) -- 'tools'?"""
try:
r = sess.post(OLLAMA_BASIS + "/api/show", json={"model": name}, timeout=8)
r.raise_for_status()
return "tools" in (r.json().get("capabilities") or [])
except Exception:
return False # nicht belegbar -> konservativ als nicht tool-fähig
def lade_ollama_modelle(session=None) -> list[ModellInfo]:
"""Lokal installierte Ollama-Modelle (/api/tags), mit Tool-Fähigkeit.
`frei=True` (lokal = ohne Kosten). `tools` aus /api/show -- nur tool-fähige
Modelle taugen für die Kategorisierung. Läuft Ollama nicht, kommt eine LEERE
Liste zurück (kein Fehler -- der Anbieter ist optional), nicht geraten.
"""
sess = session
if sess is None:
import requests
sess = requests
try:
r = sess.get(OLLAMA_BASIS + "/api/tags", timeout=5)
r.raise_for_status()
eintraege = r.json().get("models", [])
except Exception:
return []
out: list[ModellInfo] = []
for m in eintraege:
name = m.get("name") or m.get("model") or ""
if not name:
continue
out.append(
ModellInfo(
id=name,
name=name,
context=None,
frei=True,
tools=_ollama_kann_tools(sess, name),
)
)
# tool-fähige zuerst, dann alphabetisch -- die brauchbaren oben.
return sorted(out, key=lambda mi: (not mi.tools, mi.id))

View file

@ -44,16 +44,24 @@ def _angebot_dict(ka: KategorisiertesAngebot) -> dict:
def als_struktur(
fetch: FetchErgebnis,
kategorisiert: list[KategorisiertesAngebot],
*,
modell: str | None = None,
anbieter: str | None = None,
) -> dict:
"""Strukturierte Ausgabe für die Web-UI -- dieselben belegten Felder wie der
Markdown-Renderer, nur als JSON-fähiges dict. Leere Gruppen bleiben enthalten
(als leere Liste) -- kein Weglassen, kein Auffüllen."""
(als leere Liste) -- kein Weglassen, kein Auffüllen.
`modell`/`anbieter` belegen, WOMIT kategorisiert wurde -- damit in der UI
nachvollziehbar bleibt, welches LLM die Einordnung vorgenommen hat."""
gruppen = gruppieren(kategorisiert)
return {
"ort_plz": fetch.ort_plz,
"ort_name": fetch.ort_name,
"anzahl": len(kategorisiert),
"unsicher": sum(1 for k in kategorisiert if k.unsicher),
"modell": modell,
"anbieter": anbieter,
"quellen": list(fetch.abgedeckte_quellen),
"haendler": list(fetch.gesehene_haendler),
"hinweise": list(fetch.hinweise),

View file

@ -62,6 +62,17 @@ def api_modelle(q: str = "") -> list[dict]:
]
@app.get("/api/ollama-modelle")
def api_ollama_modelle() -> list[dict]:
"""Lokal installierte Ollama-Modelle. Leere Liste, wenn Ollama nicht läuft."""
from .modelle import lade_ollama_modelle
return [
{"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context}
for m in lade_ollama_modelle()
]
# === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) =========
@ -160,7 +171,9 @@ def api_kategorisieren(req: dict) -> dict:
job_id = uuid.uuid4().hex[:12]
# Cache-Treffer? Dann sofort als fertiger Job ausliefern, kein neuer Lauf.
treffer = _ergebnis_cache.get((plz, modell))
# Anbieter ist Teil des Schlüssels -- dasselbe Modell-Kürzel kann je Anbieter
# etwas anderes bedeuten.
treffer = _ergebnis_cache.get((plz, anbieter, modell))
if treffer is not None:
with _jobs_lock:
_jobs[job_id] = {
@ -207,6 +220,9 @@ def _run_kategorisieren(job_id, plz, fetch, modell, anbieter, key) -> None:
from .uebersicht import als_struktur
kt = baue_kategorisierer(anbieter, modell, api_key=key)
# Tatsächlich genutztes Modell (Default je Anbieter aufgelöst) -- für die
# sichtbare Herkunft im Ergebnis.
modell_genutzt = getattr(kt, "_modell", modell)
def fort(done, total):
job["done"] = done
@ -214,9 +230,11 @@ def _run_kategorisieren(job_id, plz, fetch, modell, anbieter, key) -> None:
kat = kategorisiere(list(fetch.angebote), kt, fortschritt=fort)
job["ergebnis"] = als_struktur(fetch, kat)
job["ergebnis"] = als_struktur(
fetch, kat, modell=modell_genutzt, anbieter=anbieter
)
job["status"] = "fertig"
_ergebnis_cache[(plz, modell)] = job["ergebnis"]
_ergebnis_cache[(plz, anbieter, modell)] = job["ergebnis"]
except AbbruchFehler as e:
job["status"] = "fehler"
job["fehler"] = e.als_text()

View file

@ -140,7 +140,12 @@
.config > summary .chev { transition:transform .2s; color:var(--muted); }
.config[open] > summary .chev { transform:rotate(90deg); }
.config > summary .ico { font-size:14px; }
.config > summary .gilt { margin-left:auto; font-weight:500; font-size:12px; color:var(--muted); }
.config > summary .gewaehlt-chip {
margin-left:auto; font-size:12px; font-weight:600; color:var(--brand-d);
background:var(--brand-weak); border:1px solid #cfe3d9; border-radius:999px;
padding:2px 11px; max-width:46ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.config > summary .gilt { margin-left:var(--s3); font-weight:500; font-size:12px; color:var(--muted); }
.config-body { padding:0 var(--s6) var(--s6); border-top:1px solid var(--line-2); padding-top:var(--s4); }
.keyrow { width:100%; max-width:560px; }
.keyrow input { width:100%; }
@ -239,21 +244,29 @@
</div>
</section>
<!-- ============ OPENROUTER-KONFIG (separat) ============ -->
<!-- ============ LLM-KONFIG (separat) ============ -->
<details class="config" id="config">
<summary>
<span class="chev"></span>
<span class="ico"></span>
<span>OpenRouter-Konfiguration</span>
<span>LLM-Konfiguration</span>
<span class="gewaehlt-chip" id="gewaehltChip" title="aktuell gewähltes Modell"></span>
<span class="gilt">gilt für Stufe&nbsp;2</span>
</summary>
<div class="config-body">
<div class="row">
<div class="field">
<label for="anbieter">Anbieter</label>
<select id="anbieter">
<option value="openrouter">OpenRouter (Cloud)</option>
<option value="ollama">Ollama (lokal)</option>
</select>
</div>
<div class="field">
<label for="modell">Modell</label>
<select id="modell"><option>lädt…</option></select>
</div>
<div class="field">
<div class="field" id="suchfeld">
<label for="modellsuche">Modell suchen</label>
<input id="modellsuche" type="text" placeholder="z. B. qwen" />
</div>
@ -262,13 +275,13 @@
<button id="refresh" class="ghost icon" title="Modell-Liste aktualisieren">↻ aktualisieren</button>
</div>
</div>
<div class="row" style="margin-top:var(--s4)">
<div class="row" style="margin-top:var(--s4)" id="keyzeile">
<div class="field keyrow">
<label for="key">API-Key (bleibt lokal, nur an deinen Server)</label>
<input id="key" type="password" placeholder="OPENROUTER_API_KEY — optional, falls nicht in der Server-Umgebung" autocomplete="off" />
</div>
</div>
<p class="hint">Der Key wird ausschließlich für Stufe&nbsp;2 verwendet und nie in Stufe&nbsp;1 (Datenabruf) eingesetzt.</p>
<p class="hint" id="konfighint">Der Key wird ausschließlich für Stufe&nbsp;2 verwendet und nie in Stufe&nbsp;1 (Datenabruf) eingesetzt.</p>
</div>
</details>
@ -319,26 +332,56 @@ function preisFmt(p){ return p!=null ? p.toLocaleString("de-DE",{minimumFraction
function setRohStatus(html){ $("#rohstatus").innerHTML = html; }
function setStatus(html){ $("#status").innerHTML = html; }
// ----- Modelle (OpenRouter-Konfig) ------------------------------------------
// ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) -------------------------
function anbieter(){ return $("#anbieter").value; }
// Hält das dauerhaft sichtbare "gewähltes Modell"-Chip im Konfig-Kopf aktuell --
// auch wenn das Panel zugeklappt ist.
function setGewaehlt(){
const sel = $("#modell");
const opt = sel.options[sel.selectedIndex];
const m = (sel.value && opt && !opt.disabled) ? sel.value : null;
const ab = anbieter() === "ollama" ? "Ollama (lokal)" : "OpenRouter";
$("#gewaehltChip").textContent = m ? `${m} · ${ab}` : `kein Modell · ${ab}`;
}
async function ladeModelle(q=""){
const sel = $("#modell");
const ab = anbieter();
// Anbieter-spezifische UI: Ollama braucht weder Key noch Suche.
$("#keyzeile").style.display = ab === "ollama" ? "none" : "";
$("#suchfeld").style.display = ab === "ollama" ? "none" : "";
$("#konfighint").textContent = ab === "ollama"
? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. Nur tool-fähige Modelle eignen sich."
: "Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.";
const url = ab === "ollama" ? "/api/ollama-modelle" : ("/api/modelle?q=" + encodeURIComponent(q));
sel.innerHTML = "<option>lädt…</option>";
try {
const r = await fetch("/api/modelle?q=" + encodeURIComponent(q));
const r = await fetch(url);
if (!r.ok) throw new Error((await r.json()).detail || r.status);
const liste = await r.json();
sel.innerHTML = "";
if (!liste.length){ sel.innerHTML = "<option value=''>(keine Treffer)</option>"; return; }
if (!liste.length){
sel.innerHTML = ab === "ollama"
? "<option value=''>(kein lokales Modell — Ollama gestartet? 'ollama serve')</option>"
: "<option value=''>(keine Treffer)</option>";
setGewaehlt(); return;
}
for (const m of liste){
const o = document.createElement("option");
o.value = m.id;
const tag = m.frei ? "FREE" : "paid";
const tag = ab === "ollama" ? "lokal" : (m.frei ? "FREE" : "paid");
const tools = m.tools ? "" : " ⚠ kein tool-calling";
o.textContent = `${m.id} [${tag}]${tools}`;
if (!m.tools) o.disabled = true;
sel.appendChild(o);
}
const ok = [...sel.options].find(o => !o.disabled && o.value);
if (ok) sel.value = ok.value; // erstes brauchbares (tool-fähiges) vorwählen
setGewaehlt();
} catch (e){
sel.innerHTML = `<option value=''>Modelle nicht abrufbar</option>`;
setGewaehlt();
}
}
@ -438,8 +481,8 @@ async function kategorisiere(){
const r = await fetch("/api/kategorisieren", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({
plz, modell: $("#modell").value, anbieter:"openrouter",
key: $("#key").value || undefined
plz, modell: $("#modell").value, anbieter: anbieter(),
key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined
})
});
const d = await r.json();
@ -477,9 +520,12 @@ async function kategorisiere(){
function render(){
const d = DATEN;
$("#result").hidden = false;
const modellTxt = d.modell
? ` <span class="sep">·</span> kategorisiert mit <b>${esc(d.modell)}</b>${d.anbieter?` <span class="t-h">(${esc(d.anbieter)})</span>`:""}`
: "";
$("#summary").innerHTML =
`Ort <b>${esc(d.ort_name || d.ort_plz)}</b> (PLZ ${esc(d.ort_plz)}) <span class="sep">·</span> <b>${d.anzahl}</b> Angebote <span class="sep">·</span>
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher`;
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher${modellTxt}`;
const fh = $("#fhaendler");
const aktuell = fh.value;
@ -548,6 +594,8 @@ $("#rohholen").onclick = holeRohdaten;
$("#kategorisieren").onclick = kategorisiere;
$("#refresh").onclick = () => ladeModelle($("#modellsuche").value.trim());
$("#modellsuche").addEventListener("keydown", e => { if (e.key==="Enter") ladeModelle(e.target.value.trim()); });
$("#anbieter").addEventListener("change", () => ladeModelle()); // Anbieter umschalten -> Liste neu
$("#modell").addEventListener("change", setGewaehlt); // Auswahl -> sichtbares Chip aktualisieren
$("#plz").addEventListener("keydown", e => { if (e.key==="Enter") holeRohdaten(); });
let _plzTimer;
$("#plz").addEventListener("input", () => {
@ -559,12 +607,24 @@ for (const id of ["#fhaendler","#ftext","#fsicher"])
$(id).addEventListener("input", () => DATEN && zeichneGruppen());
// ----- Init ------------------------------------------------------------------
ladeModelle();
// Auto: ?plz=… vorbelegen (überschreibt den Default), dann GENAU EINMAL prüfen.
const _p = new URLSearchParams(location.search);
if (_p.get("plz")) $("#plz").value = _p.get("plz").trim();
pruefeRohstand($("#plz").value.trim());
// Bookmarkbar: ?plz=… [&anbieter=…] [&modell=…] [&auto=1]
(async function init(){
const _p = new URLSearchParams(location.search);
if (_p.get("anbieter")) $("#anbieter").value = _p.get("anbieter");
await ladeModelle();
const m = _p.get("modell");
if (m){
const sel = $("#modell");
if (![...sel.options].some(o => o.value === m)){ // auch nicht-gelistetes zulassen
const o = document.createElement("option"); o.value = o.textContent = m;
sel.insertBefore(o, sel.firstChild);
}
sel.value = m; setGewaehlt();
}
if (_p.get("plz")) $("#plz").value = _p.get("plz").trim();
await pruefeRohstand($("#plz").value.trim());
if (_p.get("auto") && ROHDA) kategorisiere(); // Stufe 2 automatisch (wenn Rohdaten da)
})();
</script>
</body>
</html>

View file

@ -141,4 +141,28 @@ def test_openrouter_429_erschoepft_bricht_als_ratelimit_ab():
with pytest.raises(AbbruchFehler) as exc:
kat.klassifiziere([{"id": "x", "titel": "Apfel"}])
assert "Rate-Limit" in exc.value.schwelle
assert seq.calls == 3
assert seq.calls == 3
def test_baue_kategorisierer_ollama_ohne_key():
from angebote.kategorisieren import OllamaKategorisierer, baue_kategorisierer
# Ollama braucht KEINEN Key -- darf also nicht abbrechen.
kt = baue_kategorisierer("ollama", "qwen3.5:latest")
assert isinstance(kt, OllamaKategorisierer)
def test_ollama_kategorisierer_parst_tool_calls_lokal():
from angebote.kategorisieren import OllamaKategorisierer
a = beispiel_angebot("Apfel")
payload = _openrouter_payload(
[{"id": a.angebot_id, "gruppe": "Obst & Gemüse", "unsicher": False}]
)
sess = _FakeSession(payload)
kt = OllamaKategorisierer(modell="qwen3.5:latest", session=sess)
ergebnis = kategorisiere([a], kt)
assert ergebnis[0].gruppe == "Obst & Gemüse"
# OpenAI-kompatibel an den lokalen Endpoint adressiert:
assert sess.calls[0]["url"].endswith("/chat/completions")
assert "localhost:11434" in sess.calls[0]["url"]

View file

@ -134,3 +134,48 @@ def test_picker_quit_gibt_none():
ausgabe=lambda s: None,
)
assert gewaehlt is None
# --- Ollama (lokale Modelle) -------------------------------------------------
class _OllamaSession:
"""Fake für /api/tags (Liste) + /api/show (Capabilities)."""
def __init__(self, namen, caps):
self._namen = namen
self._caps = caps
def get(self, url, timeout=None):
return _Resp({"models": [{"name": n} for n in self._namen]})
def post(self, url, json=None, timeout=None):
name = (json or {}).get("model")
return _Resp({"capabilities": self._caps.get(name, [])})
def test_lade_ollama_modelle_tool_faehige_zuerst():
from angebote.modelle import lade_ollama_modelle
sess = _OllamaSession(
["gemma3:latest", "qwen3.5:latest", "nomic-embed:latest"],
{
"qwen3.5:latest": ["completion", "tools"],
"gemma3:latest": ["completion", "vision"],
"nomic-embed:latest": ["embedding"],
},
)
modelle = lade_ollama_modelle(session=sess)
assert all(m.frei for m in modelle) # lokal = frei
assert modelle[0].id == "qwen3.5:latest" and modelle[0].tools is True
assert any(m.id == "gemma3:latest" and not m.tools for m in modelle)
def test_lade_ollama_modelle_leer_wenn_server_aus():
from angebote.modelle import lade_ollama_modelle
class _Down:
def get(self, *a, **k):
raise OSError("connection refused")
assert lade_ollama_modelle(session=_Down()) == []

View file

@ -75,6 +75,26 @@ def test_api_status_unbekannt_ist_404():
assert r.status_code == 404
def test_als_struktur_fuehrt_modell_und_anbieter():
a = beispiel_angebot("Butter")
kat = [KategorisiertesAngebot(a, "Molkereiprodukte & Eier", unsicher=False)]
s = als_struktur(_fetch_mit([a]), kat, modell="qwen3.5:latest", anbieter="ollama")
assert s["modell"] == "qwen3.5:latest"
assert s["anbieter"] == "ollama"
def test_api_ollama_modelle(monkeypatch):
fake = [ModellInfo("qwen3.5:latest", "qwen3.5:latest", None, True, True)]
monkeypatch.setattr(
"angebote.modelle.lade_ollama_modelle", lambda session=None: fake
)
client = TestClient(web.app)
r = client.get("/api/ollama-modelle")
assert r.status_code == 200
daten = r.json()
assert daten[0]["id"] == "qwen3.5:latest" and daten[0]["tools"] is True
# === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) =========