feat: wire hybrid provider into CLI

This commit is contained in:
2026-07-30 18:06:46 +02:00
parent ab6ea485dc
commit af9426edc4
6 changed files with 354 additions and 18 deletions
+12 -4
View File
@@ -90,6 +90,11 @@ Rootserver:
`JAVIS_DATA_DIR\javis.toml` geladen; Umgebungsvariablen können sie sicher `JAVIS_DATA_DIR\javis.toml` geladen; Umgebungsvariablen können sie sicher
überschreiben. Die Aktivierungsvorlage enthält `free_only = true` und die überschreiben. Die Aktivierungsvorlage enthält `free_only = true` und die
lokale Billing-Bestätigung, aber niemals einen API-Key. lokale Billing-Bestätigung, aber niemals einen API-Key.
- `javis configure gemini` verlangt zwei ausdrückliche Bestätigungen und lässt
Gemini bei leerer oder negativer Antwort deaktiviert.
- Der echte CLI-Chat verwendet nun `hybrid/auto`; der Key wird erst vor einem
zulässigen Cloudaufruf gelesen. `/provider`, `/privacy` und `/status` zeigen
ausschließlich bereinigte Modi, Entscheidungen und Zähler.
## Aktuelle Architektur ## Aktuelle Architektur
@@ -143,6 +148,9 @@ Rootserver:
Cloudclient, `ask` verlangt Freigabe, 429/Netzwerk/Keyfehler fallen lokal zurück. Cloudclient, `ask` verlangt Freigabe, 429/Netzwerk/Keyfehler fallen lokal zurück.
- Lokalen TOML-Loader und sicheren Erzeuger ergänzt; vorhandene Konfigurationen - Lokalen TOML-Loader und sicheren Erzeuger ergänzt; vorhandene Konfigurationen
werden nicht ungefragt überschrieben und fehlerhafte TOML wird abgelehnt. werden nicht ungefragt überschrieben und fehlerhafte TOML wird abgelehnt.
- Hybridprovider in die CLI verdrahtet, sicheren Aktivierungsdialog und
bereinigte Provider-/Datenschutz-/Statusbefehle ergänzt.
- Lesender Ollama-Status prüft nur Loopback-Erreichbarkeit und Modellliste.
## Aktuelle Tests ## Aktuelle Tests
@@ -157,7 +165,8 @@ Letzter bestätigter Projektstand:
- uv-Lock und `uv sync --dev`: bestanden - uv-Lock und `uv sync --dev`: bestanden
- Python in `.venv`: 3.12.13 - Python in `.venv`: 3.12.13
- Ruff in `.venv`: 0.16.0 - Ruff in `.venv`: 0.16.0
- Unit-Tests: 55 bestanden; zusätzlich 4 lokale Konfigurationstests - Unit-Tests: 59 bestanden; CLI-Befehle, doppelte Bestätigung, verzögertes
Key-Laden und Ollama-Status sind abgedeckt
- Ruff Lint: bestanden - Ruff Lint: bestanden
- Ruff Formatprüfung: bestanden - Ruff Formatprüfung: bestanden
@@ -243,9 +252,8 @@ Abnahmestatus:
Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket: Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket:
1. sichere Aktivierungsbestätigung als CLI-Einrichtungsschritt ergänzen 1. sicheres `scripts/start-javis.ps1` ohne Systemänderungen ergänzen
2. Hybridrouter in die CLI verdrahten und `/provider`, `/privacy`, `/status` 2. Start- und Einrichtungsablauf kompakt dokumentieren und automatisiert prüfen
mit standardmäßig abgelehnter `ask`-Freigabe implementieren
3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen 3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen. Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
+7 -4
View File
@@ -55,7 +55,10 @@ Stand: lokaler Textchat auf `main`, 30.07.2026.
Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar
- lokale nicht geheime TOML-Konfiguration außerhalb von Git; konservativer - lokale nicht geheime TOML-Konfiguration außerhalb von Git; konservativer
Erzeuger überschreibt keine vorhandene Datei Erzeuger überschreibt keine vorhandene Datei
- 55 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber - `javis configure gemini` mit doppelter ausdrücklicher Bestätigung
- echter Chat auf Hybridprovider verdrahtet; Key wird verzögert geladen
- `/provider`, `/privacy` und `/status` geben keine Inhalte oder Keywerte aus
- 59 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber
## Nicht implementiert ## Nicht implementiert
@@ -80,6 +83,6 @@ Living-Mind-Frontend und automatische Ollama-Prozessverwaltung.
Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werden Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werden
noch nicht gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion. noch nicht gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion.
`qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet. `qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet.
Der Hybridprovider ist implementiert, aber noch nicht in der echten CLI verdrahtet. Das sichere Startskript und die abschließende Bedienungsdokumentation fehlen noch.
CLI-Aktivierungsdialog, Statusbefehle und Startskript fehlen. Es wurde kein echter Es wurde kein echter API-Key angefordert, angezeigt oder konfiguriert und keine
API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage ausgeführt. Cloudanfrage ausgeführt.
+180 -8
View File
@@ -6,27 +6,38 @@ import argparse
import getpass import getpass
import sys import sys
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from pathlib import Path
from javis.config.local_config import LocalConfigError, write_gemini_activation
from javis.config.settings import ConfigurationError, Settings from javis.config.settings import ConfigurationError, Settings
from javis.core.chat_service import ChatService, SessionProviderMismatchError from javis.core.chat_service import ChatService, SessionProviderMismatchError
from javis.core.provider_router import HybridProvider
from javis.memory.sqlite_store import ( from javis.memory.sqlite_store import (
ChatSession, ChatSession,
SessionNotFoundError, SessionNotFoundError,
SessionStoreError, SessionStoreError,
SQLiteSessionStore, SQLiteSessionStore,
) )
from javis.providers.base import ProviderError from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError
from javis.providers.base import MissingApiKeyError, ProviderError
from javis.providers.gemini import GeminiProvider
from javis.providers.ollama import OllamaProvider from javis.providers.ollama import OllamaProvider
from javis.security.privacy import PrivacyDecision, PrivacyRouter
from javis.security.secrets import SecretProvider, SecretStoreError from javis.security.secrets import SecretProvider, SecretStoreError
InputFunction = Callable[[str], str] InputFunction = Callable[[str], str]
OutputFunction = Callable[[str], None] OutputFunction = Callable[[str], None]
StatusFunction = Callable[[], list[str]]
HELP_TEXT = """Befehle: HELP_TEXT = """Befehle:
/new neue Sitzung beginnen /new neue Sitzung beginnen
/sessions gespeicherte Sitzungen auflisten /sessions gespeicherte Sitzungen auflisten
/load <ID> frühere Sitzung laden und fortsetzen /load <ID> frühere Sitzung laden und fortsetzen
/clear Nachrichten der aktiven Sitzung leeren /clear Nachrichten der aktiven Sitzung leeren
/provider aktiven Providermodus anzeigen
/provider <MODUS> Modus auto, local oder gemini setzen
/privacy letzte lokale Datenschutzentscheidung anzeigen
/status bereinigten Providerstatus anzeigen
/help diese Hilfe anzeigen /help diese Hilfe anzeigen
/exit Chat ordentlich beenden""" /exit Chat ordentlich beenden"""
@@ -36,6 +47,11 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten") chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten")
chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden") chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden")
configure_parser = subparsers.add_parser(
"configure",
help="nicht geheime lokale Einstellungen einrichten",
)
configure_parser.add_argument("provider_name", choices=["gemini"])
secrets_parser = subparsers.add_parser("secrets", help="lokale Geheimwerte sicher verwalten") secrets_parser = subparsers.add_parser("secrets", help="lokale Geheimwerte sicher verwalten")
secret_subparsers = secrets_parser.add_subparsers(dest="secret_action", required=True) secret_subparsers = secrets_parser.add_subparsers(dest="secret_action", required=True)
set_parser = secret_subparsers.add_parser("set", help="Geheimwert verdeckt setzen") set_parser = secret_subparsers.add_parser("set", help="Geheimwert verdeckt setzen")
@@ -46,6 +62,34 @@ def build_parser() -> argparse.ArgumentParser:
return parser return parser
def _confirmed(answer: str) -> bool:
return answer.strip().lower() in {"j", "ja", "y", "yes"}
def run_gemini_configuration(
config_path: Path,
*,
input_fn: InputFunction = input,
output: OutputFunction = print,
) -> int:
if not _confirmed(input_fn("Gemini Free Tier lokal aktivieren? [j/N] ")):
output("Gemini bleibt deaktiviert.")
return 1
if not _confirmed(
input_fn("Bestätigst du, dass für dieses Google-Projekt kein Billing aktiviert ist? [j/N] ")
):
output("Gemini bleibt deaktiviert; keine Konfiguration wurde geschrieben.")
return 1
try:
write_gemini_activation(config_path)
except LocalConfigError as exc:
output(f"Konfigurationsfehler: {exc}")
return 2
output(f"Gemini-Free-Konfiguration gespeichert: {config_path}")
output("Nächster Schritt: javis secrets set gemini")
return 0
def run_secrets( def run_secrets(
action: str, action: str,
*, *,
@@ -106,6 +150,7 @@ def run_chat(
session_id: str | None = None, session_id: str | None = None,
input_fn: InputFunction = input, input_fn: InputFunction = input,
output: OutputFunction = print, output: OutputFunction = print,
status_fn: StatusFunction | None = None,
) -> int: ) -> int:
try: try:
if session_id: if session_id:
@@ -160,6 +205,40 @@ def run_chat(
except (SessionNotFoundError, SessionStoreError) as exc: except (SessionNotFoundError, SessionStoreError) as exc:
output(f"Fehler: {exc}") output(f"Fehler: {exc}")
continue continue
if entered.startswith("/provider"):
parts = entered.split()
provider = service.provider
if len(parts) == 1:
output(f"Aktiver Providermodus: {getattr(provider, 'mode', 'local')}")
continue
if len(parts) != 2 or parts[1] not in {"auto", "local", "gemini"}:
output("Verwendung: /provider auto|local|gemini")
continue
set_mode = getattr(provider, "set_mode", None)
if not callable(set_mode):
output("Dieser Chat unterstützt keinen wechselbaren Providermodus.")
continue
set_mode(parts[1])
output(f"Providermodus: {parts[1]}")
continue
if entered == "/privacy":
route = getattr(service.provider, "last_route", None)
if route is None:
output("Noch keine Datenschutzentscheidung in dieser Sitzung.")
else:
output(
"Letzte Datenschutzentscheidung: "
f"{route.privacy_policy.value}; Provider: {route.provider}; "
f"Fallback: {'ja' if route.fallback else 'nein'}"
)
continue
if entered == "/status":
if status_fn is None:
output("Keine erweiterte Statusprüfung verfügbar.")
else:
for line in status_fn():
output(line)
continue
if entered.startswith("/load"): if entered.startswith("/load"):
parts = entered.split(maxsplit=1) parts = entered.split(maxsplit=1)
if len(parts) != 2 or not parts[1].strip(): if len(parts) != 2 or not parts[1].strip():
@@ -190,6 +269,88 @@ def run_chat(
output(f"Fehler: {exc}") output(f"Fehler: {exc}")
def build_runtime(
settings: Settings,
*,
input_fn: InputFunction,
output: OutputFunction,
secret_provider: SecretProvider | None = None,
) -> tuple[ChatService, StatusFunction]:
store = SQLiteSessionStore(settings.database_path)
usage_store = SQLiteUsageStore(settings.usage_database_path)
secrets = secret_provider or SecretProvider()
local = OllamaProvider(
settings.model,
settings.ollama_base_url,
settings.timeout_seconds,
)
def approve(decision: PrivacyDecision) -> bool:
answer = input_fn(
f"Cloudfreigabe erforderlich ({decision.reason}). Einmalig erlauben? [j/N] "
)
return _confirmed(answer)
def create_cloud_provider() -> GeminiProvider:
try:
key = secrets.get_gemini_key()
except SecretStoreError as exc:
raise MissingApiKeyError(
"Der Gemini-Schlüssel konnte nicht sicher geladen werden."
) from exc
return GeminiProvider(
model=settings.gemini_model,
api_key=key,
timeout_seconds=settings.gemini_timeout_seconds,
max_output_tokens=settings.max_cloud_output_tokens,
max_retries=settings.gemini_max_retries,
)
hybrid = HybridProvider(
local_provider=local,
cloud_provider_factory=create_cloud_provider,
cloud_model=settings.gemini_model,
privacy_router=PrivacyRouter(),
usage_store=usage_store,
approval_callback=approve,
notice_callback=output,
mode=settings.provider_mode,
cloud_enabled=settings.gemini_enabled,
billing_confirmed_disabled=settings.billing_confirmed_disabled,
free_only=settings.free_only,
max_cloud_requests_per_day=settings.max_cloud_requests_per_day,
max_cloud_input_chars=settings.max_cloud_input_chars,
max_cloud_context_messages=settings.max_cloud_context_messages,
)
def status() -> list[str]:
ollama = local.probe()
try:
key_available = secrets.gemini_key_available()
except SecretStoreError:
key_available = False
gemini_configured = (
settings.gemini_enabled and settings.free_only and settings.billing_confirmed_disabled
)
try:
cloud_requests = usage_store.cloud_requests_on()
local_fallbacks = usage_store.local_fallbacks_on()
except UsageStoreError:
cloud_requests = local_fallbacks = -1
return [
f"Ollama erreichbar: {'ja' if ollama.reachable else 'nein'}",
f"Lokales Modell vorhanden: {'ja' if ollama.model_available else 'nein'}",
f"Gemini-Schlüssel vorhanden: {'ja' if key_available else 'nein'}",
f"Gemini konfiguriert: {'ja' if gemini_configured else 'nein'}",
f"Nullkostenmodus: {'aktiv' if settings.free_only else 'inaktiv'}",
f"Aktiver Providermodus: {hybrid.mode}",
f"Heutige Cloudanfragen: {cloud_requests if cloud_requests >= 0 else 'unbekannt'}",
f"Heutige lokale Fallbacks: {local_fallbacks if local_fallbacks >= 0 else 'unbekannt'}",
]
return ChatService(store, hybrid), status
def main( def main(
argv: Sequence[str] | None = None, argv: Sequence[str] | None = None,
*, *,
@@ -203,26 +364,37 @@ def main(
password_fn=input_fn if input_fn is not input else getpass.getpass, password_fn=input_fn if input_fn is not input else getpass.getpass,
output=output, output=output,
) )
if args.command == "configure":
try:
settings = Settings.from_env()
except ConfigurationError as exc:
output(f"Konfigurationsfehler: {exc}")
return 2
return run_gemini_configuration(
settings.config_path,
input_fn=input_fn,
output=output,
)
if args.command != "chat": if args.command != "chat":
return 2 return 2
try: try:
settings = Settings.from_env() settings = Settings.from_env()
store = SQLiteSessionStore(settings.database_path) service, status_fn = build_runtime(
provider = OllamaProvider( settings,
settings.model, input_fn=input_fn,
settings.ollama_base_url, output=output,
settings.timeout_seconds,
) )
except (ConfigurationError, SessionStoreError) as exc: except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
output(f"Konfigurationsfehler: {exc}") output(f"Konfigurationsfehler: {exc}")
return 2 return 2
return run_chat( return run_chat(
ChatService(store, provider), service,
session_id=args.session, session_id=args.session,
input_fn=input_fn, input_fn=input_fn,
output=output, output=output,
status_fn=status_fn,
) )
+28 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import socket import socket
from dataclasses import dataclass
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -17,14 +18,40 @@ from javis.providers.base import (
) )
@dataclass(frozen=True, slots=True)
class OllamaStatus:
reachable: bool
model_available: bool
class OllamaProvider: class OllamaProvider:
name = "ollama" name = "ollama"
def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None: def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None:
self.model = model self.model = model
self._endpoint = f"{base_url.rstrip('/')}/api/chat" self._base_url = base_url.rstrip("/")
self._endpoint = f"{self._base_url}/api/chat"
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
def probe(self) -> OllamaStatus:
request = Request(f"{self._base_url}/api/tags", method="GET")
try:
with urlopen(request, timeout=min(self._timeout_seconds, 3)) as response:
result = json.loads(response.read())
except (HTTPError, URLError, TimeoutError, OSError, json.JSONDecodeError):
return OllamaStatus(False, False)
models = result.get("models")
if not isinstance(models, list):
return OllamaStatus(True, False)
names = {
value
for item in models
if isinstance(item, dict)
for value in (item.get("name"), item.get("model"))
if isinstance(value, str)
}
return OllamaStatus(True, self.model in names)
def chat(self, messages: list[ChatMessage]) -> str: def chat(self, messages: list[ChatMessage]) -> str:
payload = { payload = {
"model": self.model, "model": self.model,
+116 -1
View File
@@ -1,11 +1,15 @@
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from javis.config.settings import Settings
from javis.core.chat_service import ChatService from javis.core.chat_service import ChatService
from javis.interface.cli import run_chat from javis.core.provider_router import HybridProvider
from javis.interface.cli import build_runtime, run_chat, run_gemini_configuration
from javis.memory.sqlite_store import SQLiteSessionStore from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy
class _FakeProvider: class _FakeProvider:
@@ -16,6 +20,30 @@ class _FakeProvider:
return f"Echo: {messages[-1].content}" return f"Echo: {messages[-1].content}"
class _FakeHybridProvider(_FakeProvider):
name = "hybrid"
model = "auto"
def __init__(self) -> None:
self.mode = "auto"
self.last_route = None
def set_mode(self, mode: str) -> None:
self.mode = mode
class _LazySecrets:
def __init__(self) -> None:
self.reads = 0
def get_gemini_key(self) -> str | None:
self.reads += 1
return None
def gemini_key_available(self) -> bool:
return False
class CliTests(unittest.TestCase): class CliTests(unittest.TestCase):
def test_basic_chat_commands(self) -> None: def test_basic_chat_commands(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -35,6 +63,93 @@ class CliTests(unittest.TestCase):
self.assertTrue(any("Nachrichten" in line for line in output)) self.assertTrue(any("Nachrichten" in line for line in output))
self.assertEqual(output[-1], "Chat beendet.") self.assertEqual(output[-1], "Chat beendet.")
def test_provider_privacy_and_status_commands_are_sanitized(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
provider = _FakeHybridProvider()
provider.last_route = SimpleNamespace(
privacy_policy=CloudPolicy.ASK,
provider="ollama",
fallback=True,
)
service = ChatService(store, provider)
inputs = iter(
[
"/provider",
"/provider local",
"/privacy",
"/status",
"/exit",
]
)
output: list[str] = []
result = run_chat(
service,
input_fn=lambda _prompt: next(inputs),
output=output.append,
status_fn=lambda: [
"Gemini-Schlüssel vorhanden: ja",
"Heutige Cloudanfragen: 0",
],
)
self.assertEqual(result, 0)
self.assertIn("Aktiver Providermodus: auto", output)
self.assertIn("Providermodus: local", output)
self.assertTrue(any("ask; Provider: ollama; Fallback: ja" in line for line in output))
self.assertIn("Gemini-Schlüssel vorhanden: ja", output)
def test_gemini_configuration_defaults_to_no(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "javis.toml"
output: list[str] = []
result = run_gemini_configuration(
path,
input_fn=lambda _prompt: "",
output=output.append,
)
self.assertEqual(result, 1)
self.assertFalse(path.exists())
self.assertIn("deaktiviert", output[-1])
def test_gemini_configuration_requires_both_confirmations(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "javis.toml"
answers = iter(["ja", "ja"])
result = run_gemini_configuration(
path,
input_fn=lambda _prompt: next(answers),
output=lambda _line: None,
)
self.assertEqual(result, 0)
self.assertTrue(path.exists())
content = path.read_text(encoding="utf-8")
self.assertIn("billing_confirmed_disabled = true", content)
self.assertNotIn("api_key", content.lower())
def test_runtime_wires_hybrid_provider_without_loading_key_at_start(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = Settings(data_dir=Path(directory))
secrets = _LazySecrets()
service, _status = build_runtime(
settings,
input_fn=lambda _prompt: "",
output=lambda _line: None,
secret_provider=secrets,
)
session = service.new_session()
self.assertIsInstance(service.provider, HybridProvider)
self.assertEqual(session.provider, "hybrid")
self.assertEqual(session.model, "auto")
self.assertEqual(secrets.reads, 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+11
View File
@@ -26,6 +26,14 @@ class _OllamaHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def do_GET(self) -> None:
body = json.dumps({"models": [{"name": "test-model"}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None: def log_message(self, format: str, *args: object) -> None:
return return
@@ -42,6 +50,7 @@ class OllamaProviderTests(unittest.TestCase):
2, 2,
) )
response = provider.chat([ChatMessage("user", "Hallo")]) response = provider.chat([ChatMessage("user", "Hallo")])
status = provider.probe()
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
@@ -50,6 +59,8 @@ class OllamaProviderTests(unittest.TestCase):
self.assertEqual(response, "Lokale Antwort") self.assertEqual(response, "Lokale Antwort")
self.assertEqual(_OllamaHandler.request_payload["model"], "test-model") self.assertEqual(_OllamaHandler.request_payload["model"], "test-model")
self.assertFalse(_OllamaHandler.request_payload["stream"]) self.assertFalse(_OllamaHandler.request_payload["stream"])
self.assertTrue(status.reachable)
self.assertTrue(status.model_available)
if __name__ == "__main__": if __name__ == "__main__":