feat: add privacy-aware hybrid routing
This commit is contained in:
@@ -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 "
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
Reference in New Issue
Block a user