- 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>
181 lines
5.2 KiB
Python
181 lines
5.2 KiB
Python
"""Modell-Discovery + interaktive Auswahl -- offline, ohne Netz/Key."""
|
|
|
|
from angebote.modelle import lade_modelle, parse_modelle, suche, top_free
|
|
from angebote.modellauswahl import waehle_modell_interaktiv
|
|
|
|
FAKE = [
|
|
{
|
|
"id": "moonshotai/kimi-k2.6:free",
|
|
"name": "Kimi K2",
|
|
"context_length": 262144,
|
|
"pricing": {"prompt": "0", "completion": "0"},
|
|
"supported_parameters": ["tools", "response_format"],
|
|
},
|
|
{
|
|
"id": "meta-llama/llama-3.3-70b-instruct:free",
|
|
"name": "Llama 3.3 70B",
|
|
"context_length": 131072,
|
|
"pricing": {"prompt": "0", "completion": "0"},
|
|
"supported_parameters": ["tools"],
|
|
},
|
|
{
|
|
"id": "some/free-no-tools:free",
|
|
"name": "Free ohne Tools",
|
|
"context_length": 100000,
|
|
"pricing": {"prompt": "0", "completion": "0"},
|
|
"supported_parameters": [],
|
|
},
|
|
{
|
|
"id": "anthropic/claude-sonnet-4.6",
|
|
"name": "Claude Sonnet 4.6",
|
|
"context_length": 200000,
|
|
"pricing": {"prompt": "3", "completion": "15"},
|
|
"supported_parameters": ["tools"],
|
|
},
|
|
]
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, payload):
|
|
self._p = payload
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return self._p
|
|
|
|
|
|
class _Session:
|
|
def __init__(self, data):
|
|
self._data = data
|
|
|
|
def get(self, url, timeout=None):
|
|
return _Resp({"data": self._data})
|
|
|
|
|
|
def _scripted(antworten):
|
|
it = iter(antworten)
|
|
return lambda prompt="": next(it)
|
|
|
|
|
|
# --- reine Daten-Logik -------------------------------------------------------
|
|
|
|
|
|
def test_parse_erkennt_frei_und_tools():
|
|
modelle = parse_modelle(FAKE)
|
|
nach_id = {m.id: m for m in modelle}
|
|
assert nach_id["moonshotai/kimi-k2.6:free"].frei is True
|
|
assert nach_id["moonshotai/kimi-k2.6:free"].tools is True
|
|
assert nach_id["anthropic/claude-sonnet-4.6"].frei is False
|
|
assert nach_id["some/free-no-tools:free"].tools is False
|
|
|
|
|
|
def test_top_free_nur_tools_und_rangfolge():
|
|
modelle = parse_modelle(FAKE)
|
|
top = top_free(modelle, 5, nur_tools=True)
|
|
ids = [m.id for m in top]
|
|
# paid (Sonnet) und das tool-lose Free-Modell sind raus:
|
|
assert "anthropic/claude-sonnet-4.6" not in ids
|
|
assert "some/free-no-tools:free" not in ids
|
|
# Präferenz: kimi-k2 vor llama-3.3-70b
|
|
assert ids == [
|
|
"moonshotai/kimi-k2.6:free",
|
|
"meta-llama/llama-3.3-70b-instruct:free",
|
|
]
|
|
|
|
|
|
def test_suche_findet_teilstring():
|
|
modelle = parse_modelle(FAKE)
|
|
treffer = suche(modelle, "llama")
|
|
assert [m.id for m in treffer] == ["meta-llama/llama-3.3-70b-instruct:free"]
|
|
|
|
|
|
def test_lade_modelle_ueber_session():
|
|
modelle = lade_modelle(session=_Session(FAKE))
|
|
assert len(modelle) == 4
|
|
|
|
|
|
# --- interaktiver Picker -----------------------------------------------------
|
|
|
|
|
|
def test_picker_waehlt_per_nummer():
|
|
gewaehlt = waehle_modell_interaktiv(
|
|
session=_Session(FAKE),
|
|
eingabe=_scripted(["1"]),
|
|
ausgabe=lambda s: None,
|
|
)
|
|
assert gewaehlt == "moonshotai/kimi-k2.6:free"
|
|
|
|
|
|
def test_picker_suche_dann_wahl():
|
|
gewaehlt = waehle_modell_interaktiv(
|
|
session=_Session(FAKE),
|
|
eingabe=_scripted(["s llama", "1"]),
|
|
ausgabe=lambda s: None,
|
|
)
|
|
assert gewaehlt == "meta-llama/llama-3.3-70b-instruct:free"
|
|
|
|
|
|
def test_picker_warnt_bei_modell_ohne_tools_und_bricht_ab():
|
|
# Suche bringt das tool-lose Modell in die Liste; Wahl -> Warnung -> 'n' -> q.
|
|
gewaehlt = waehle_modell_interaktiv(
|
|
session=_Session(FAKE),
|
|
eingabe=_scripted(["s no-tools", "1", "n", "q"]),
|
|
ausgabe=lambda s: None,
|
|
)
|
|
assert gewaehlt is None
|
|
|
|
|
|
def test_picker_quit_gibt_none():
|
|
gewaehlt = waehle_modell_interaktiv(
|
|
session=_Session(FAKE),
|
|
eingabe=_scripted(["q"]),
|
|
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()) == []
|