feat: wire hybrid provider into CLI
This commit is contained in:
+12
-4
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user