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
+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."
)