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
überschreiben. Die Aktivierungsvorlage enthält `free_only = true` und die
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
@@ -143,6 +148,9 @@ Rootserver:
Cloudclient, `ask` verlangt Freigabe, 429/Netzwerk/Keyfehler fallen lokal zurück.
- Lokalen TOML-Loader und sicheren Erzeuger ergänzt; vorhandene Konfigurationen
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
@@ -157,7 +165,8 @@ Letzter bestätigter Projektstand:
- uv-Lock und `uv sync --dev`: bestanden
- Python in `.venv`: 3.12.13
- 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 Formatprüfung: bestanden
@@ -243,9 +252,8 @@ Abnahmestatus:
Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket:
1. sichere Aktivierungsbestätigung als CLI-Einrichtungsschritt ergänzen
2. Hybridrouter in die CLI verdrahten und `/provider`, `/privacy`, `/status`
mit standardmäßig abgelehnter `ask`-Freigabe implementieren
1. sicheres `scripts/start-javis.ps1` ohne Systemänderungen ergänzen
2. Start- und Einrichtungsablauf kompakt dokumentieren und automatisiert prüfen
3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen
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
- lokale nicht geheime TOML-Konfiguration außerhalb von Git; konservativer
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
@@ -80,6 +83,6 @@ Living-Mind-Frontend und automatische Ollama-Prozessverwaltung.
Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werden
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.
Der Hybridprovider ist implementiert, aber noch nicht in der echten CLI verdrahtet.
CLI-Aktivierungsdialog, Statusbefehle und Startskript fehlen. Es wurde kein echter
API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage ausgeführt.
Das sichere Startskript und die abschließende Bedienungsdokumentation fehlen noch.
Es wurde kein echter API-Key angefordert, angezeigt oder konfiguriert und keine
Cloudanfrage ausgeführt.
+180 -8
View File
@@ -6,27 +6,38 @@ import argparse
import getpass
import sys
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.core.chat_service import ChatService, SessionProviderMismatchError
from javis.core.provider_router import HybridProvider
from javis.memory.sqlite_store import (
ChatSession,
SessionNotFoundError,
SessionStoreError,
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.security.privacy import PrivacyDecision, PrivacyRouter
from javis.security.secrets import SecretProvider, SecretStoreError
InputFunction = Callable[[str], str]
OutputFunction = Callable[[str], None]
StatusFunction = Callable[[], list[str]]
HELP_TEXT = """Befehle:
/new neue Sitzung beginnen
/sessions gespeicherte Sitzungen auflisten
/load <ID> frühere Sitzung laden und fortsetzen
/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
/exit Chat ordentlich beenden"""
@@ -36,6 +47,11 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True)
chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten")
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")
secret_subparsers = secrets_parser.add_subparsers(dest="secret_action", required=True)
set_parser = secret_subparsers.add_parser("set", help="Geheimwert verdeckt setzen")
@@ -46,6 +62,34 @@ def build_parser() -> argparse.ArgumentParser:
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(
action: str,
*,
@@ -106,6 +150,7 @@ def run_chat(
session_id: str | None = None,
input_fn: InputFunction = input,
output: OutputFunction = print,
status_fn: StatusFunction | None = None,
) -> int:
try:
if session_id:
@@ -160,6 +205,40 @@ def run_chat(
except (SessionNotFoundError, SessionStoreError) as exc:
output(f"Fehler: {exc}")
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"):
parts = entered.split(maxsplit=1)
if len(parts) != 2 or not parts[1].strip():
@@ -190,6 +269,88 @@ def run_chat(
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(
argv: Sequence[str] | None = None,
*,
@@ -203,26 +364,37 @@ def main(
password_fn=input_fn if input_fn is not input else getpass.getpass,
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":
return 2
try:
settings = Settings.from_env()
store = SQLiteSessionStore(settings.database_path)
provider = OllamaProvider(
settings.model,
settings.ollama_base_url,
settings.timeout_seconds,
service, status_fn = build_runtime(
settings,
input_fn=input_fn,
output=output,
)
except (ConfigurationError, SessionStoreError) as exc:
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
output(f"Konfigurationsfehler: {exc}")
return 2
return run_chat(
ChatService(store, provider),
service,
session_id=args.session,
input_fn=input_fn,
output=output,
status_fn=status_fn,
)
+28 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import socket
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
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:
name = "ollama"
def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None:
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
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:
payload = {
"model": self.model,
+116 -1
View File
@@ -1,11 +1,15 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from javis.config.settings import Settings
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.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy
class _FakeProvider:
@@ -16,6 +20,30 @@ class _FakeProvider:
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):
def test_basic_chat_commands(self) -> None:
with tempfile.TemporaryDirectory() as directory:
@@ -35,6 +63,93 @@ class CliTests(unittest.TestCase):
self.assertTrue(any("Nachrichten" in line for line in output))
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__":
unittest.main()
+11
View File
@@ -26,6 +26,14 @@ class _OllamaHandler(BaseHTTPRequestHandler):
self.end_headers()
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:
return
@@ -42,6 +50,7 @@ class OllamaProviderTests(unittest.TestCase):
2,
)
response = provider.chat([ChatMessage("user", "Hallo")])
status = provider.probe()
finally:
server.shutdown()
server.server_close()
@@ -50,6 +59,8 @@ class OllamaProviderTests(unittest.TestCase):
self.assertEqual(response, "Lokale Antwort")
self.assertEqual(_OllamaHandler.request_payload["model"], "test-model")
self.assertFalse(_OllamaHandler.request_payload["stream"])
self.assertTrue(status.reachable)
self.assertTrue(status.model_available)
if __name__ == "__main__":