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