diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index fb7307b..07a83a1 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -70,6 +70,14 @@ Rootserver: der Windows Credential Locker verwendet. - Secret-CLI: `javis secrets set gemini`, `status` und `delete gemini`; Setzen verwendet verdeckte Eingabe und kein Befehl gibt den Schlüsselwert aus. +- Lokaler Datenschutzrouter klassifiziert jeden späteren Cloudaufruf konservativ + als `allowed`, `ask` oder `never`; `never` kann auch durch einen ausdrücklichen + Cloudwunsch nicht überstimmt werden. +- Nullkosten-Vorgaben und lokale Schutzgrenzen sind zentral konfiguriert: + Gemini standardmäßig deaktiviert, `free_only = true`, keine bestätigte + Billing-Freiheit ohne Pascals lokale Bestätigung, höchstens ein Retry. +- Provider-Nutzung wird separat in SQLite ausschließlich als technische Metadaten + protokolliert; Prompts, Antworten und Secrets werden dort nicht gespeichert. ## Aktuelle Architektur @@ -115,6 +123,8 @@ Rootserver: Auth-Keys, GA-SDK, Fehlercodes und Datenschutzbedingungen geprüft und unter `docs/GEMINI_PROVIDER.md` kompakt dokumentiert. - Betriebssystemgebundenen Secret-Provider und isolierte Fake-Backend-Tests ergänzt. +- Datenschutzrouter, minimale Cloud-Kontextauswahl, strikte Nullkosten-Defaults, + lokale Tages-/Größenlimits und metadatenbasiertes Provider-Audit ergänzt. ## Aktuelle Tests @@ -129,7 +139,8 @@ 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: 18 bestanden, davon 5 für Secret-Speicher und Secret-CLI +- Unit-Tests: 30 bestanden, davon 8 für Datenschutzrouting und Kontextminimierung, + 3 für Nullkosten-/Limitkonfiguration sowie 1 für das metadatenbasierte Audit - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -171,6 +182,7 @@ Abnahmestatus: - Feature-Dokumentation: `5d567e5` - lokaler Merge nach `main`: `f3d6dfb` - geprüfter und gepushter Abschlussstand vor diesem Handoff-Update: `0a550e6` +- Secret-Provider: `570e7fa` - `origin` verwendet HTTPS - kein Force-Push und keine umgeschriebene Historie @@ -190,7 +202,8 @@ Abnahmestatus: - Startmechanismus für Ollama ist noch bewusst manuell und nur pro Prozess konfiguriert - Antworten werden noch nicht gestreamt; Sitzungen besitzen noch keine Titel oder Suche - genauer späterer Obsidian-Schreibbereich ist nicht freigegeben -- endgültiger Secret-Provider ist offen; lokale Klartext-XML wäre nur restriktiv geschützt und Git-ignoriert zulässig +- normaler Secret-Provider ist festgelegt: Betriebssystem-Keyring; eine + Klartext-XML wird nicht für API-Schlüssel verwendet - Google-Quoten bleiben projektabhängig und müssen in AI Studio geprüft werden; lokale Limits sind nur zusätzliche Schutzgrenzen - Gaming-PC zeigt derzeit nur 8 statt 16 logische CPU-Prozessoren; Ursache ungeklärt @@ -211,14 +224,12 @@ Abnahmestatus: ## Nächster sinnvoller Auftrag -Als separates nächstes Featurepaket auf neuem Branch: +Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket: -1. offizielle aktuelle Gemini-Free-Tier-Modelle und Kontingente neu prüfen -2. Datenschutzrouting `allowed`/`ask`/`never` vor jedem Cloudaufruf erzwingen -3. Gemini als kostenlosen bevorzugten Provider und Ollama als lokalen - Datenschutz-/Offline-/429-Fallback implementieren -4. `free_only = true`, keine Billing-Aktivierung und alle geforderten Erfolgs-, - Key-, 429-, Netzwerk-, Fallback- und Datenschutztests nachweisen +1. offiziellen `google-genai`-Client anbinden +2. Gemini-Erfolg sowie fehlenden/ungültigen Schlüssel, 429, Netzwerkfehler und + höchstens einen Retry vollständig mit Test-Doubles prüfen +3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 0daacb9..cd012bd 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -40,7 +40,12 @@ Stand: lokaler Textchat auf `main`, 30.07.2026. - offizielle Gemini-Rahmenbedingungen geprüft; Standardmodell `gemini-3.6-flash` - `keyring` 25.7.0 mit Windows Credential Locker als normalem Secret-Speicher - verdeckte Secret-Befehle `set`, `status` und `delete` -- 18 Unit-Tests aktuell erfolgreich +- lokaler Datenschutzrouter mit `allowed`, `ask` und nicht übersteuerbarem `never` +- minimale Cloud-Kontextauswahl schließt sensible Verlaufsteile aus +- konservative Nullkosten-Defaults und harte lokale Tages-, Größen-, Timeout- + und Retry-Grenzen +- separates SQLite-Provider-Audit speichert nur Metadaten, keine Inhalte +- 30 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber ## Nicht implementiert @@ -49,7 +54,8 @@ Living-Mind-Frontend und automatische Ollama-Prozessverwaltung. ## Git -- Branch: `main` +- stabiler Branch: `main` +- aktueller Branch: `feat/gemini-privacy-router` - Grundgerüstcommit: `6d04171` - Basis des Feature-Branches: `e6eb995` - Toolchain: `fa90a5c` @@ -64,5 +70,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. -Gemini ist als separates kostenloses Folgepaket mit lokalem Datenschutzrouting -geplant, aber noch nicht implementiert oder mit einem API-Key konfiguriert. +Der Gemini-Netzwerkprovider und das automatische Ollama-Fallback sind noch nicht +implementiert. Es wurde kein echter API-Key angefordert, angezeigt oder +konfiguriert und noch keine Cloudanfrage ausgeführt. diff --git a/src/javis/config/settings.py b/src/javis/config/settings.py index cf31186..34fde5d 100644 --- a/src/javis/config/settings.py +++ b/src/javis/config/settings.py @@ -13,6 +13,35 @@ class ConfigurationError(ValueError): """Raised when runtime configuration is unsafe or invalid.""" +def _boolean(values: Mapping[str, str], name: str, default: bool) -> bool: + raw = values.get(name) + if raw is None: + return default + normalized = raw.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ConfigurationError(f"{name} muss true oder false sein.") + + +def _positive_integer( + values: Mapping[str, str], + name: str, + default: int, + *, + maximum: int, +) -> int: + raw = values.get(name, str(default)) + try: + result = int(raw) + except ValueError as exc: + raise ConfigurationError(f"{name} muss eine ganze Zahl sein.") from exc + if result <= 0 or result > maximum: + raise ConfigurationError(f"{name} muss zwischen 1 und {maximum} liegen.") + return result + + def default_data_dir( environ: Mapping[str, str] | None = None, *, @@ -42,6 +71,18 @@ class Settings: model: str = "qwen3:8b" ollama_base_url: str = "http://127.0.0.1:11434" timeout_seconds: float = 180.0 + provider_mode: str = "auto" + gemini_enabled: bool = False + gemini_model: str = "gemini-3.6-flash" + gemini_optional_model: str = "gemini-3.5-flash-lite" + free_only: bool = True + billing_confirmed_disabled: bool = False + max_cloud_requests_per_day: int = 25 + max_cloud_input_chars: int = 12_000 + max_cloud_output_tokens: int = 1_024 + gemini_timeout_seconds: float = 30.0 + gemini_max_retries: int = 1 + max_cloud_context_messages: int = 6 @property def database_path(self) -> Path: @@ -59,11 +100,20 @@ class Settings: model = values.get("JAVIS_MODEL", "qwen3:8b").strip() base_url = values.get("JAVIS_OLLAMA_URL", "http://127.0.0.1:11434").strip() timeout_raw = values.get("JAVIS_MODEL_TIMEOUT", "180") + provider_mode = values.get("JAVIS_PROVIDER_MODE", "auto").strip().lower() + gemini_enabled = _boolean(values, "JAVIS_GEMINI_ENABLED", False) + free_only = _boolean(values, "JAVIS_FREE_ONLY", True) + billing_disabled = _boolean(values, "JAVIS_GEMINI_BILLING_CONFIRMED_DISABLED", False) + gemini_timeout_raw = values.get("JAVIS_GEMINI_TIMEOUT", "30") try: timeout = float(timeout_raw) except ValueError as exc: raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss eine Zahl sein.") from exc + try: + gemini_timeout = float(gemini_timeout_raw) + except ValueError as exc: + raise ConfigurationError("JAVIS_GEMINI_TIMEOUT muss eine Zahl sein.") from exc settings = cls( data_dir=data_dir.resolve(), @@ -71,6 +121,29 @@ class Settings: model=model, ollama_base_url=base_url, timeout_seconds=timeout, + provider_mode=provider_mode, + gemini_enabled=gemini_enabled, + gemini_model=values.get("JAVIS_GEMINI_MODEL", "gemini-3.6-flash").strip(), + gemini_optional_model=values.get( + "JAVIS_GEMINI_OPTIONAL_MODEL", "gemini-3.5-flash-lite" + ).strip(), + free_only=free_only, + billing_confirmed_disabled=billing_disabled, + max_cloud_requests_per_day=_positive_integer( + values, "JAVIS_MAX_CLOUD_REQUESTS_PER_DAY", 25, maximum=1_000 + ), + max_cloud_input_chars=_positive_integer( + values, "JAVIS_MAX_CLOUD_INPUT_CHARS", 12_000, maximum=1_000_000 + ), + max_cloud_output_tokens=_positive_integer( + values, "JAVIS_MAX_CLOUD_OUTPUT_TOKENS", 1_024, maximum=65_536 + ), + gemini_timeout_seconds=gemini_timeout, + gemini_max_retries=_positive_integer(values, "JAVIS_GEMINI_MAX_ATTEMPTS", 2, maximum=2) + - 1, + max_cloud_context_messages=_positive_integer( + values, "JAVIS_MAX_CLOUD_CONTEXT_MESSAGES", 6, maximum=50 + ), ) settings.validate() return settings @@ -84,6 +157,22 @@ class Settings: raise ConfigurationError("JAVIS_MODEL darf nicht leer sein.") if self.timeout_seconds <= 0: raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss größer als 0 sein.") + if self.provider_mode not in {"auto", "local", "gemini"}: + raise ConfigurationError("JAVIS_PROVIDER_MODE muss auto, local oder gemini sein.") + if not self.free_only: + raise ConfigurationError( + "Kostenpflichtiger Betrieb wird nicht unterstützt; JAVIS_FREE_ONLY muss true sein." + ) + if self.gemini_model != "gemini-3.6-flash": + raise ConfigurationError( + "In diesem Paket ist nur das geprüfte Standardmodell gemini-3.6-flash aktiv." + ) + if not self.gemini_optional_model: + raise ConfigurationError("JAVIS_GEMINI_OPTIONAL_MODEL darf nicht leer sein.") + if self.gemini_timeout_seconds <= 0 or self.gemini_timeout_seconds > 60: + raise ConfigurationError( + "JAVIS_GEMINI_TIMEOUT muss größer als 0 und höchstens 60 sein." + ) parsed = urlparse(self.ollama_base_url) if parsed.scheme != "http" or parsed.hostname not in { diff --git a/src/javis/memory/usage_store.py b/src/javis/memory/usage_store.py new file mode 100644 index 0000000..8706b8f --- /dev/null +++ b/src/javis/memory/usage_store.py @@ -0,0 +1,114 @@ +"""Metadata-only provider audit and local usage limits.""" + +from __future__ import annotations + +import sqlite3 +from contextlib import closing +from dataclasses import dataclass +from datetime import UTC, date, datetime +from pathlib import Path + + +class UsageStoreError(RuntimeError): + """Provider metadata could not be stored or read.""" + + +@dataclass(frozen=True, slots=True) +class ProviderEvent: + provider: str + model: str + success: bool + error_category: str | None + fallback: bool + privacy_policy: str + input_tokens: int | None = None + output_tokens: int | None = None + + +class SQLiteUsageStore: + def __init__(self, database_path: Path) -> None: + self.database_path = database_path + try: + self.database_path.parent.mkdir(parents=True, exist_ok=True) + with closing(self._connect()) as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS provider_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + success INTEGER NOT NULL, + error_category TEXT, + fallback INTEGER NOT NULL, + privacy_policy TEXT NOT NULL, + input_tokens INTEGER, + output_tokens INTEGER + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_provider_events_time_provider + ON provider_events(occurred_at, provider) + """ + ) + connection.commit() + except (OSError, sqlite3.Error) as exc: + raise UsageStoreError( + f"Die Nutzungsmetadatenbank kann nicht geöffnet werden: {database_path}" + ) from exc + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.database_path, timeout=10) + + def record(self, event: ProviderEvent) -> None: + try: + with closing(self._connect()) as connection: + connection.execute( + """ + INSERT INTO provider_events ( + occurred_at, provider, model, success, error_category, + fallback, privacy_policy, input_tokens, output_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + datetime.now(UTC).isoformat(timespec="seconds"), + event.provider, + event.model, + int(event.success), + event.error_category, + int(event.fallback), + event.privacy_policy, + event.input_tokens, + event.output_tokens, + ), + ) + connection.commit() + except sqlite3.Error as exc: + raise UsageStoreError("Nutzungsmetadaten konnten nicht gespeichert werden.") from exc + + def cloud_requests_on(self, day: date | None = None) -> int: + return self._count(day or datetime.now(UTC).date(), "provider = 'gemini'") + + def local_fallbacks_on(self, day: date | None = None) -> int: + return self._count( + day or datetime.now(UTC).date(), + "provider = 'ollama' AND fallback = 1", + ) + + def _count(self, day: date, condition: str) -> int: + start = f"{day.isoformat()}T00:00:00+00:00" + end = f"{day.isoformat()}T23:59:59+00:00" + try: + with closing(self._connect()) as connection: + row = connection.execute( + f""" + SELECT COUNT(*) FROM provider_events + WHERE occurred_at BETWEEN ? AND ? AND {condition} + """, + (start, end), + ).fetchone() + except sqlite3.Error as exc: + raise UsageStoreError("Nutzungsmetadaten konnten nicht gelesen werden.") from exc + return int(row[0]) diff --git a/src/javis/security/privacy.py b/src/javis/security/privacy.py new file mode 100644 index 0000000..a81ed0f --- /dev/null +++ b/src/javis/security/privacy.py @@ -0,0 +1,151 @@ +"""Conservative local privacy classification for cloud routing.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import StrEnum + +from javis.providers.base import ChatMessage + + +class CloudPolicy(StrEnum): + ALLOWED = "allowed" + ASK = "ask" + NEVER = "never" + + +@dataclass(frozen=True, slots=True) +class PrivacyDecision: + policy: CloudPolicy + reason: str + + +_NEVER_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile( + r"(?i)\b(api[-_ ]?key|passwort|password|access[-_ ]?token|refresh[-_ ]?token|" + r"session[-_ ]?cookie|private[rn]? ssh[-_ ]?schl[üu]ssel)\b" + ), + "mögliches Secret", + ), + ( + re.compile(r"(?i)(AIza[\w-]{20,}|sk-[\w-]{16,}|BEGIN [A-Z ]*PRIVATE KEY)"), + "Schlüsselmuster", + ), + ( + re.compile( + r"(?i)\b(iban|bic|kreditkart|bankkonto|bankdaten|zahlungsdaten|" + r"kontonummer|pin[- ]?code)\b" + ), + "Finanz- oder Zahlungsdaten", + ), + ( + re.compile( + r"(?i)\b(diagnose|gesundheit|krankheit|medikament|patient|arztbericht|" + r"psychotherapie|blutwert)\b" + ), + "Gesundheitsinformation", + ), + ( + re.compile( + r"(?i)\b(genaue anschrift|wohnanschrift|private[rs]? dokument|" + r"vollständige[rs]? personenprofil|rohe? obsidian|nur lokal|" + r"ausschließlich lokal)\b" + ), + "ausdrücklich lokal oder besonders sensibel", + ), +) + +_ASK_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile( + r"(?i)\b(meine frau|mein mann|meine familie|mein kind|meine mutter|" + r"mein vater|meine beziehung|über mich|über pascal)\b" + ), + "persönliche oder familiäre Information", + ), + ( + re.compile(r"(?i)\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b"), + "E-Mail-Adresse", + ), + ( + re.compile(r"(? PrivacyDecision: + normalized = text.strip() + for pattern, reason in _NEVER_PATTERNS: + if pattern.search(normalized): + return PrivacyDecision(CloudPolicy.NEVER, reason) + for pattern, reason in _ASK_PATTERNS: + if pattern.search(normalized): + return PrivacyDecision(CloudPolicy.ASK, reason) + if any(pattern.search(normalized) for pattern in _ALLOWED_PATTERNS): + return PrivacyDecision(CloudPolicy.ALLOWED, "allgemeiner oder technischer Inhalt") + if cloud_requested: + return PrivacyDecision( + CloudPolicy.ASK, + "expliziter Cloudwunsch bei uneindeutigem Inhalt", + ) + return PrivacyDecision(CloudPolicy.ASK, "Inhalt nicht eindeutig klassifizierbar") + + def minimal_context( + self, + messages: list[ChatMessage], + *, + current_policy: CloudPolicy, + approved: bool, + max_chars: int, + max_messages: int, + ) -> list[ChatMessage]: + if current_policy is CloudPolicy.NEVER: + return [] + if current_policy is CloudPolicy.ASK and not approved: + return [] + + selected: list[ChatMessage] = [] + remaining = max_chars + for index in range(len(messages) - 1, max(-1, len(messages) - max_messages - 1), -1): + message = messages[index] + decision = self.classify(message.content) + is_current = index == len(messages) - 1 + may_include = decision.policy is CloudPolicy.ALLOWED or ( + is_current and current_policy is CloudPolicy.ASK and approved + ) + if not may_include: + continue + content = message.content[:remaining] + if not content: + break + selected.append(ChatMessage(message.role, content)) + remaining -= len(content) + if remaining <= 0: + break + selected.reverse() + return selected diff --git a/tests/unit/test_privacy.py b/tests/unit/test_privacy.py new file mode 100644 index 0000000..7e9e483 --- /dev/null +++ b/tests/unit/test_privacy.py @@ -0,0 +1,73 @@ +import unittest + +from javis.providers.base import ChatMessage +from javis.security.privacy import CloudPolicy, PrivacyRouter + + +class PrivacyRouterTests(unittest.TestCase): + def setUp(self) -> None: + self.router = PrivacyRouter() + + def test_general_technical_question_is_allowed(self) -> None: + self.assertEqual( + self.router.classify("Wie funktioniert SQLite in Python?").policy, + CloudPolicy.ALLOWED, + ) + + def test_personal_information_requires_approval(self) -> None: + self.assertEqual( + self.router.classify("Ich möchte meinen Alltag besser planen.").policy, + CloudPolicy.ASK, + ) + + def test_family_information_requires_approval(self) -> None: + self.assertEqual( + self.router.classify("Meine Frau hat morgen einen Termin.").policy, + CloudPolicy.ASK, + ) + + def test_secret_pattern_is_never(self) -> None: + decision = self.router.classify("Mein API-Key ist AIza" + "x" * 25) + self.assertEqual(decision.policy, CloudPolicy.NEVER) + + def test_financial_and_health_information_are_never(self) -> None: + self.assertEqual( + self.router.classify("Meine IBAN ist ein Geheimnis.").policy, + CloudPolicy.NEVER, + ) + self.assertEqual( + self.router.classify("Meine Diagnose soll ausgewertet werden.").policy, + CloudPolicy.NEVER, + ) + + def test_unknown_content_is_conservatively_ask(self) -> None: + self.assertEqual( + self.router.classify("Erkläre das bitte genauer.").policy, + CloudPolicy.ASK, + ) + + def test_explicit_cloud_request_cannot_override_never(self) -> None: + decision = self.router.classify( + "Sende mein Passwort an die Cloud.", + cloud_requested=True, + ) + self.assertEqual(decision.policy, CloudPolicy.NEVER) + + def test_minimal_context_excludes_sensitive_history(self) -> None: + messages = [ + ChatMessage("user", "Meine Diagnose ist privat."), + ChatMessage("assistant", "Das bleibt lokal."), + ChatMessage("user", "Wie funktioniert SQLite?"), + ] + selected = self.router.minimal_context( + messages, + current_policy=CloudPolicy.ALLOWED, + approved=False, + max_chars=1_000, + max_messages=6, + ) + self.assertEqual(selected, [ChatMessage("user", "Wie funktioniert SQLite?")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index f98a4e1..8366f0b 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -34,6 +34,33 @@ class SettingsTests(unittest.TestCase): } ) + def test_zero_cost_defaults_are_conservative(self) -> None: + settings = Settings.from_env({"JAVIS_DATA_DIR": str(Path.cwd())}) + + self.assertTrue(settings.free_only) + self.assertFalse(settings.gemini_enabled) + self.assertFalse(settings.billing_confirmed_disabled) + self.assertEqual(settings.gemini_model, "gemini-3.6-flash") + self.assertEqual(settings.provider_mode, "auto") + + def test_paid_mode_is_rejected(self) -> None: + with self.assertRaises(ConfigurationError): + Settings.from_env( + { + "JAVIS_DATA_DIR": str(Path.cwd()), + "JAVIS_FREE_ONLY": "false", + } + ) + + def test_cloud_limits_are_bounded(self) -> None: + with self.assertRaises(ConfigurationError): + Settings.from_env( + { + "JAVIS_DATA_DIR": str(Path.cwd()), + "JAVIS_MAX_CLOUD_REQUESTS_PER_DAY": "1001", + } + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_usage_store.py b/tests/unit/test_usage_store.py new file mode 100644 index 0000000..e729fb4 --- /dev/null +++ b/tests/unit/test_usage_store.py @@ -0,0 +1,46 @@ +import tempfile +import unittest +from datetime import UTC, datetime +from pathlib import Path + +from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore + + +class UsageStoreTests(unittest.TestCase): + def test_records_only_metadata_and_counts_daily_usage(self) -> None: + with tempfile.TemporaryDirectory() as directory: + database = Path(directory) / "usage.sqlite3" + store = SQLiteUsageStore(database) + store.record( + ProviderEvent( + provider="gemini", + model="gemini-test", + success=False, + error_category="quota", + fallback=False, + privacy_policy="allowed", + input_tokens=12, + output_tokens=None, + ) + ) + store.record( + ProviderEvent( + provider="ollama", + model="local-test", + success=True, + error_category=None, + fallback=True, + privacy_policy="allowed", + ) + ) + + today = datetime.now(UTC).date() + self.assertEqual(store.cloud_requests_on(today), 1) + self.assertEqual(store.local_fallbacks_on(today), 1) + raw_database = database.read_bytes() + self.assertNotIn(b"prompt", raw_database) + self.assertNotIn(b"answer", raw_database) + + +if __name__ == "__main__": + unittest.main()