feat: add privacy-aware hybrid routing

This commit is contained in:
2026-07-30 18:00:20 +02:00
parent f3e11831fb
commit e57186f678
6 changed files with 541 additions and 11 deletions
+11 -5
View File
@@ -81,7 +81,11 @@ Rootserver:
- Isolierter Gemini-Provider nutzt das offizielle `google-genai` 2.16.0 mit API
`v1`, begrenztem Timeout und maximal einem Retry; 429 wird nicht wiederholt.
- Fehlender/ungültiger Schlüssel, 429, Netzwerkfehler und ungültige Antworten
werden als getrennte, bereinigte Fehler signalisiert. Noch kein Routing aktiv.
werden als getrennte, bereinigte Fehler signalisiert.
- Hybridrouter erzwingt lokal Datenschutz, Freigabe, Billing-Bestätigung,
Tages-/Größenlimits und sichtbares Ollama-Fallback vor jedem Cloudaufruf.
- Ein abgelehnter Schlüssel deaktiviert Gemini für den restlichen CLI-Prozess;
alte reine Ollama-Sitzungen bleiben mit dem Hybridprovider ladbar.
## Aktuelle Architektur
@@ -131,6 +135,8 @@ Rootserver:
lokale Tages-/Größenlimits und metadatenbasiertes Provider-Audit ergänzt.
- Offiziellen Gemini-SDK-Client isoliert angebunden und ausschließlich mit
Test-Doubles gegen Erfolg, Key-, Quota-, Netzwerk- und Antwortfehler geprüft.
- Hybridrouting und Audit mit Test-Doubles integriert: `never` konstruiert keinen
Cloudclient, `ask` verlangt Freigabe, 429/Netzwerk/Keyfehler fallen lokal zurück.
## Aktuelle Tests
@@ -145,7 +151,7 @@ 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: 37 bestanden; zusätzlich 7 für den isolierten Gemini-Provider
- Unit-Tests: 51 bestanden; zusätzlich 13 Router- und 1 Kompatibilitätstest
- Ruff Lint: bestanden
- Ruff Formatprüfung: bestanden
@@ -231,9 +237,9 @@ Abnahmestatus:
Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket:
1. Hybridrouter vor den Chatkern setzen
2. `allowed`/`ask`/`never`, lokale Tagesgrenze und sichtbares Ollama-Fallback
für Key-, 429-, Netzwerk- und Gemini-Ausfälle vollständig mit Doubles testen
1. lokale TOML-Konfiguration und sichere Aktivierungsbestätigung ergänzen
2. Hybridrouter in die CLI verdrahten und `/provider`, `/privacy`, `/status`
mit standardmäßig abgelehnter `ask`-Freigabe implementieren
3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
+8 -5
View File
@@ -49,7 +49,11 @@ Stand: lokaler Textchat auf `main`, 30.07.2026.
begrenztem Timeout, höchstens einem Retry und keinem 429-Retry
- bereinigte, getrennte Fehler für fehlenden/ungültigen Key, Quote, Netzwerk,
Modellverfügbarkeit, Timeout und ungültige Antwort
- 37 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber
- Hybridrouter erzwingt vor Cloudaufrufen lokal `allowed`/`ask`/`never`,
Aktivierung, Billing-Bestätigung, Tages-/Größenlimit und minimierten Kontext
- sichtbares lokales Fallback für fehlenden/ungültigen Key, 429, Netzwerk,
Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar
- 51 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber
## Nicht implementiert
@@ -74,7 +78,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 Gemini-Netzwerkprovider ist isoliert implementiert, aber noch nicht in den
Chatkern geroutet. Das automatische Ollama-Fallback fehlt noch. Es wurde kein
echter API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage
ausgeführt.
Der Hybridprovider ist implementiert, aber noch nicht in der echten CLI verdrahtet.
Lokale TOML-Aktivierung, Statusbefehle und Startskript fehlen. Es wurde kein echter
API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage ausgeführt.
+7 -1
View File
@@ -35,7 +35,13 @@ class ChatService:
def load_session(self, session_id: str) -> LoadedSession:
session = self.store.get_session(session_id)
if session.provider != self.provider.name or session.model != self.provider.model:
supports_session = getattr(self.provider, "supports_session", None)
compatible = (
supports_session(session.provider, session.model)
if callable(supports_session)
else session.provider == self.provider.name and session.model == self.provider.model
)
if not compatible:
raise SessionProviderMismatchError(
"Die Sitzung verwendet "
f"{session.provider}/{session.model}, aktiv ist "
+232
View File
@@ -0,0 +1,232 @@
"""Local privacy and zero-cost routing between Gemini and Ollama."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
from javis.providers.base import (
ChatMessage,
InvalidApiKeyError,
LocalModelProvider,
MissingApiKeyError,
ProviderError,
ProviderUsage,
)
from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter
ApprovalCallback = Callable[[PrivacyDecision], bool]
NoticeCallback = Callable[[str], None]
CloudProviderFactory = Callable[[], LocalModelProvider]
@dataclass(frozen=True, slots=True)
class RouteStatus:
provider: str
privacy_policy: CloudPolicy
fallback: bool
reason: str
class HybridProvider:
"""Provider contract that enforces privacy before any cloud construction."""
name = "hybrid"
model = "auto"
def __init__(
self,
*,
local_provider: LocalModelProvider,
cloud_provider_factory: CloudProviderFactory,
cloud_model: str,
privacy_router: PrivacyRouter,
usage_store: SQLiteUsageStore,
approval_callback: ApprovalCallback,
notice_callback: NoticeCallback,
mode: str,
cloud_enabled: bool,
billing_confirmed_disabled: bool,
free_only: bool,
max_cloud_requests_per_day: int,
max_cloud_input_chars: int,
max_cloud_context_messages: int,
) -> None:
if mode not in {"auto", "local", "gemini"}:
raise ValueError("Unbekannter Provider-Modus.")
if not free_only:
raise ValueError("Der Hybridprovider unterstützt ausschließlich den Nullkostenmodus.")
self.local_provider = local_provider
self._cloud_provider_factory = cloud_provider_factory
self._cloud_model = cloud_model
self._privacy_router = privacy_router
self._usage_store = usage_store
self._approval_callback = approval_callback
self._notice_callback = notice_callback
self.mode = mode
self._cloud_enabled = cloud_enabled
self._billing_confirmed_disabled = billing_confirmed_disabled
self._max_cloud_requests_per_day = max_cloud_requests_per_day
self._max_cloud_input_chars = max_cloud_input_chars
self._max_cloud_context_messages = max_cloud_context_messages
self._cloud_session_disabled = False
self.last_route: RouteStatus | None = None
def supports_session(self, provider: str, model: str) -> bool:
return (provider, model) in {
(self.name, self.model),
(self.local_provider.name, self.local_provider.model),
}
def set_mode(self, mode: str) -> None:
if mode not in {"auto", "local", "gemini"}:
raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
self.mode = mode
def chat(self, messages: list[ChatMessage]) -> str:
current_text = messages[-1].content if messages else ""
cloud_requested = self.mode == "gemini"
decision = self._privacy_router.classify(
current_text,
cloud_requested=cloud_requested,
)
if self.mode == "local":
return self._local(messages, decision, "lokaler Modus", fallback=False)
if decision.policy is CloudPolicy.NEVER:
return self._local(messages, decision, decision.reason, fallback=True)
approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision)
if not approved:
return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True)
unavailable_reason = self._cloud_unavailable_reason()
if unavailable_reason:
return self._local(messages, decision, unavailable_reason, fallback=True)
if len(current_text) > self._max_cloud_input_chars:
return self._local(
messages,
decision,
"Nachricht überschreitet das lokale Cloud-Größenlimit",
fallback=True,
)
cloud_messages = self._privacy_router.minimal_context(
messages,
current_policy=decision.policy,
approved=approved,
max_chars=self._max_cloud_input_chars,
max_messages=self._max_cloud_context_messages,
)
if not cloud_messages:
return self._local(messages, decision, "kein freigegebener Cloudkontext", fallback=True)
try:
cloud_provider = self._cloud_provider_factory()
response = cloud_provider.chat(cloud_messages)
except InvalidApiKeyError as exc:
self._cloud_session_disabled = True
return self._cloud_failure(messages, decision, exc, disable_notice=True)
except (MissingApiKeyError, ProviderError) as exc:
return self._cloud_failure(messages, decision, exc)
usage = getattr(cloud_provider, "last_usage", ProviderUsage())
self._record(
ProviderEvent(
provider=cloud_provider.name,
model=cloud_provider.model,
success=True,
error_category=None,
fallback=False,
privacy_policy=decision.policy.value,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
)
self.last_route = RouteStatus(
cloud_provider.name,
decision.policy,
False,
"Cloudaufruf erfolgreich",
)
return response
def _cloud_unavailable_reason(self) -> str | None:
if not self._cloud_enabled:
return "Gemini ist lokal nicht aktiviert"
if not self._billing_confirmed_disabled:
return "fehlende lokale Bestätigung, dass Billing deaktiviert ist"
if self._cloud_session_disabled:
return "Gemini ist nach einem Schlüssel-Fehler für diese Sitzung deaktiviert"
try:
cloud_requests_today = self._usage_store.cloud_requests_on()
except UsageStoreError:
return "lokale Cloudlimit-Prüfung nicht verfügbar"
if cloud_requests_today >= self._max_cloud_requests_per_day:
return "lokales tägliches Cloudlimit erreicht"
return None
def _cloud_failure(
self,
messages: list[ChatMessage],
decision: PrivacyDecision,
error: ProviderError,
*,
disable_notice: bool = False,
) -> str:
error_category = type(error).__name__
self._record(
ProviderEvent(
provider="gemini",
model=self._cloud_model,
success=False,
error_category=error_category,
fallback=False,
privacy_policy=decision.policy.value,
)
)
reason = "Gemini nicht verfügbar"
if disable_notice:
reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert"
return self._local(messages, decision, reason, fallback=True)
def _local(
self,
messages: list[ChatMessage],
decision: PrivacyDecision,
reason: str,
*,
fallback: bool,
) -> str:
if fallback:
self._notice_callback(f"{reason} lokale Antwort mit Ollama.")
response = self.local_provider.chat(messages)
self._record(
ProviderEvent(
provider=self.local_provider.name,
model=self.local_provider.model,
success=True,
error_category=None,
fallback=fallback,
privacy_policy=decision.policy.value,
)
)
self.last_route = RouteStatus(
self.local_provider.name,
decision.policy,
fallback,
reason,
)
return response
def _record(self, event: ProviderEvent) -> None:
try:
self._usage_store.record(event)
except UsageStoreError:
self._notice_callback(
"Warnung: Provider-Nutzungsmetadaten konnten nicht gespeichert werden."
)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from javis.core.chat_service import ChatService
from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage
class _CompatibleHybrid:
name = "hybrid"
model = "auto"
def chat(self, messages: list[ChatMessage]) -> str:
return "Antwort"
def supports_session(self, provider: str, model: str) -> bool:
return (provider, model) in {
("hybrid", "auto"),
("ollama", "qwen3:8b"),
}
class HybridChatServiceTests(unittest.TestCase):
def test_old_local_session_remains_loadable_after_hybrid_upgrade(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
store = SQLiteSessionStore(Path(temporary_directory) / "sessions.sqlite3")
old_session = store.create_session("ollama", "qwen3:8b")
store.append_exchange(old_session.id, "Alt", "Verlauf")
service = ChatService(store, _CompatibleHybrid())
loaded = service.load_session(old_session.id)
self.assertEqual([message.content for message in loaded.messages], ["Alt", "Verlauf"])
if __name__ == "__main__":
unittest.main()
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from javis.core.provider_router import HybridProvider
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore
from javis.providers.base import (
ChatMessage,
CloudNetworkError,
InvalidApiKeyError,
LocalModelProvider,
MissingApiKeyError,
ProviderRateLimitError,
ProviderUsage,
)
from javis.security.privacy import CloudPolicy, PrivacyRouter
class _RecordingProvider:
def __init__(
self,
name: str,
model: str,
*,
answer: str = "Antwort",
error: Exception | None = None,
) -> None:
self.name = name
self.model = model
self.answer = answer
self.error = error
self.calls: list[list[ChatMessage]] = []
self.last_usage = ProviderUsage(11, 5)
def chat(self, messages: list[ChatMessage]) -> str:
self.calls.append(messages)
if self.error:
raise self.error
return self.answer
class HybridProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
self.usage_store = SQLiteUsageStore(
Path(self.temporary_directory.name) / "provider-usage.sqlite3"
)
self.local = _RecordingProvider("ollama", "qwen3:8b", answer="Lokal")
self.cloud = _RecordingProvider(
"gemini",
"gemini-3.6-flash",
answer="Cloud",
)
self.notices: list[str] = []
self.approvals: list[object] = []
self.approval_result = False
def tearDown(self) -> None:
self.temporary_directory.cleanup()
def _router(self, **overrides: object) -> HybridProvider:
arguments: dict[str, object] = {
"local_provider": self.local,
"cloud_provider_factory": lambda: self.cloud,
"cloud_model": "gemini-3.6-flash",
"privacy_router": PrivacyRouter(),
"usage_store": self.usage_store,
"approval_callback": self._approve,
"notice_callback": self.notices.append,
"mode": "auto",
"cloud_enabled": True,
"billing_confirmed_disabled": True,
"free_only": True,
"max_cloud_requests_per_day": 25,
"max_cloud_input_chars": 12_000,
"max_cloud_context_messages": 6,
}
arguments.update(overrides)
return HybridProvider(**arguments)
def _approve(self, decision: object) -> bool:
self.approvals.append(decision)
return self.approval_result
def test_contract_and_existing_local_session_are_supported(self) -> None:
router = self._router()
self.assertIsInstance(router, LocalModelProvider)
self.assertTrue(router.supports_session("hybrid", "auto"))
self.assertTrue(router.supports_session("ollama", "qwen3:8b"))
self.assertFalse(router.supports_session("ollama", "other-model"))
def test_allowed_technical_content_uses_cloud_with_minimal_context(self) -> None:
router = self._router()
answer = router.chat(
[
ChatMessage("user", "Meine IBAN ist vertraulich"),
ChatMessage("assistant", "Verstanden"),
ChatMessage("user", "Wie funktioniert SQLite?"),
]
)
self.assertEqual(answer, "Cloud")
self.assertEqual(len(self.cloud.calls), 1)
self.assertEqual(
[message.content for message in self.cloud.calls[0]],
["Wie funktioniert SQLite?"],
)
self.assertEqual(router.last_route.provider, "gemini")
self.assertEqual(router.last_route.privacy_policy, CloudPolicy.ALLOWED)
self.assertFalse(router.last_route.fallback)
def test_never_policy_cannot_construct_cloud_provider(self) -> None:
constructed = False
def cloud_factory() -> _RecordingProvider:
nonlocal constructed
constructed = True
return self.cloud
router = self._router(cloud_provider_factory=cloud_factory, mode="gemini")
answer = router.chat([ChatMessage("user", "Mein API-Key ist geheim")])
self.assertEqual(answer, "Lokal")
self.assertFalse(constructed)
self.assertTrue(router.last_route.fallback)
self.assertIn("lokale Antwort", self.notices[-1])
def test_ask_defaults_to_local_when_approval_is_denied(self) -> None:
router = self._router()
answer = router.chat([ChatMessage("user", "Meine Familie plant Urlaub")])
self.assertEqual(answer, "Lokal")
self.assertEqual(len(self.approvals), 1)
self.assertFalse(self.cloud.calls)
def test_ask_uses_cloud_after_explicit_approval(self) -> None:
self.approval_result = True
router = self._router()
answer = router.chat([ChatMessage("user", "Meine Familie plant Urlaub")])
self.assertEqual(answer, "Cloud")
self.assertEqual(len(self.cloud.calls), 1)
def test_429_falls_back_without_losing_local_answer(self) -> None:
self.cloud.error = ProviderRateLimitError("quota")
router = self._router()
answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertEqual(answer, "Lokal")
self.assertEqual(self.usage_store.cloud_requests_on(), 1)
self.assertEqual(self.usage_store.local_fallbacks_on(), 1)
self.assertIn("Gemini nicht verfügbar", self.notices[-1])
def test_network_failure_falls_back(self) -> None:
self.cloud.error = CloudNetworkError("offline")
router = self._router()
answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertEqual(answer, "Lokal")
self.assertTrue(router.last_route.fallback)
def test_missing_key_falls_back(self) -> None:
def missing_key() -> _RecordingProvider:
raise MissingApiKeyError("missing")
router = self._router(cloud_provider_factory=missing_key)
answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertEqual(answer, "Lokal")
self.assertTrue(router.last_route.fallback)
def test_invalid_key_disables_cloud_for_remaining_process(self) -> None:
self.cloud.error = InvalidApiKeyError("invalid")
router = self._router()
router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.cloud.error = None
router.chat([ChatMessage("user", "Wie funktioniert SQLite?")])
self.assertEqual(len(self.cloud.calls), 1)
self.assertEqual(len(self.local.calls), 2)
self.assertIn("deaktiviert", self.notices[-1])
def test_local_daily_limit_stops_cloud_before_construction(self) -> None:
self.usage_store.record(
ProviderEvent(
"gemini",
"gemini-3.6-flash",
True,
None,
False,
"allowed",
)
)
router = self._router(max_cloud_requests_per_day=1)
answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertEqual(answer, "Lokal")
self.assertFalse(self.cloud.calls)
self.assertIn("tägliches Cloudlimit", self.notices[-1])
def test_cloud_requires_local_activation_and_no_billing_confirmation(self) -> None:
for override in (
{"cloud_enabled": False},
{"billing_confirmed_disabled": False},
):
with self.subTest(override=override):
router = self._router(**override)
answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertEqual(answer, "Lokal")
self.assertFalse(self.cloud.calls)
def test_oversized_current_message_remains_local(self) -> None:
router = self._router(max_cloud_input_chars=10)
answer = router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertEqual(answer, "Lokal")
self.assertFalse(self.cloud.calls)
def test_local_mode_never_asks_or_calls_cloud(self) -> None:
router = self._router(mode="local")
answer = router.chat([ChatMessage("user", "Meine Familie plant Urlaub")])
self.assertEqual(answer, "Lokal")
self.assertFalse(self.approvals)
self.assertFalse(self.cloud.calls)
if __name__ == "__main__":
unittest.main()