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