21 Commits

Author SHA1 Message Date
Dystroyer8 328dcd9f74 docs: update chat comfort status 2026-07-30 19:48:57 +02:00
Dystroyer8 43c1f76d84 feat: improve session navigation 2026-07-30 19:45:05 +02:00
Dystroyer8 112ae6d1da feat: add session titles and migration 2026-07-30 19:42:32 +02:00
Dystroyer8 00a5a6ce8c feat: stream provider responses 2026-07-30 19:38:53 +02:00
Dystroyer8 9148193806 fix: route medical content strictly local 2026-07-30 19:29:55 +02:00
Dystroyer8 77b510bd22 docs: record main push status 2026-07-30 18:36:04 +02:00
Dystroyer8 b52757702d docs: record Gemini milestone merge 2026-07-30 18:35:17 +02:00
Dystroyer8 564a3fdbd3 merge: complete Gemini privacy milestone 2026-07-30 18:34:16 +02:00
Dystroyer8 7a14fe87f9 fix: refine privacy routing and provider reporting 2026-07-30 18:34:01 +02:00
Dystroyer8 a6c46f34e4 docs: record feature branch push 2026-07-30 18:13:57 +02:00
Dystroyer8 a6e4576848 docs: finalize Gemini privacy handoff 2026-07-30 18:13:01 +02:00
Dystroyer8 a0af15a037 test: cover remaining cloud fallback safeguards 2026-07-30 18:11:27 +02:00
Dystroyer8 141ff2e013 feat: add safe Javis startup script 2026-07-30 18:09:06 +02:00
Dystroyer8 af9426edc4 feat: wire hybrid provider into CLI 2026-07-30 18:06:46 +02:00
Dystroyer8 ab6ea485dc feat: add local zero-cost configuration 2026-07-30 18:03:40 +02:00
Dystroyer8 e57186f678 feat: add privacy-aware hybrid routing 2026-07-30 18:00:20 +02:00
Dystroyer8 f3e11831fb feat: add isolated Gemini provider 2026-07-30 17:57:19 +02:00
Dystroyer8 286d96da33 feat: add privacy router and zero-cost safeguards 2026-07-30 17:54:06 +02:00
Dystroyer8 570e7fa153 feat: add secure secret provider 2026-07-30 17:47:36 +02:00
Dystroyer8 a28e2d8bc9 docs: record Gemini free tier constraints 2026-07-30 17:45:11 +02:00
Dystroyer8 cc34f14ece docs: record manual core chat acceptance 2026-07-30 17:44:17 +02:00
40 changed files with 4910 additions and 194 deletions
+1
View File
@@ -15,6 +15,7 @@ key-pw.xml
secrets.local.xml secrets.local.xml
*.local.xml *.local.xml
config/javis.toml config/javis.toml
/javis.toml
config/*secret*.xml config/*secret*.xml
!config/*.example.xml !config/*.example.xml
+27
View File
@@ -18,6 +18,16 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
- SQLite-Sitzungen mit geordneter Nachrichtenhistorie außerhalb des Repositories. - SQLite-Sitzungen mit geordneter Nachrichtenhistorie außerhalb des Repositories.
- CLI-Befehle für neue, gelistete, geladene, geleerte und beendete Sitzungen. - CLI-Befehle für neue, gelistete, geladene, geleerte und beendete Sitzungen.
- Unit-Tests für Konfiguration, Speicher, Chatkern, Provider und CLI. - Unit-Tests für Konfiguration, Speicher, Chatkern, Provider und CLI.
- Sicherer Gemini-Key-Speicher über Betriebssystem-Keyring.
- Offizieller Gemini-Provider mit lokalem Datenschutzrouter
(`allowed`/`ask`/`never`) und sichtbarem Ollama-Fallback.
- Strikter Nullkostenmodus, lokale Schutzlimits und inhaltsfreies Provider-Audit.
- Bereinigte CLI-Befehle `/provider`, `/privacy` und `/status`.
- Sicheres PowerShell-Startskript ohne Systemänderungen.
- Gemeinsames Streaming für Ollama und Gemini mit atomarer Speicherung,
Gedankenfilter, lesbarer Terminalausgabe und sicherem Strg+C-Abbruch.
- Automatische lokale Sitzungstitel, `/rename`, nummerierte `/sessions`,
`/load <NR|ID>` und begrenzte `/search`-Suche.
### Changed ### Changed
@@ -29,8 +39,25 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
- Offizielle VS-Code-Erweiterungen für Python, Pylance und Python Environments eingerichtet. - Offizielle VS-Code-Erweiterungen für Python, Pylance und Python Environments eingerichtet.
- `qwen3:8b` mit echten deutschen Antworten und GPU-/Zeitmessungen auf der RTX 3060 geprüft. - `qwen3:8b` mit echten deutschen Antworten und GPU-/Zeitmessungen auf der RTX 3060 geprüft.
- Projektstatus, Architektur und Datenmodell auf den funktionierenden Textchat aktualisiert. - Projektstatus, Architektur und Datenmodell auf den funktionierenden Textchat aktualisiert.
- Chatlaufzeit auf austauschbares Hybridrouting umgestellt; alte Ollama-Sitzungen
bleiben ladbar.
- Nicht geheime Providerkonfiguration liegt lokal außerhalb von Git; ein Key wird
erst unmittelbar vor einem zulässigen Cloudaufruf geladen.
- Allgemeine unpersönliche Technikfragen werden präziser als `allowed`
klassifiziert; persönliche, interne und geheime Inhalte behalten Vorrang.
- Privacy-Ausgabe unterscheidet lokalen Modus von technischem Fallback.
- SQLite-Sitzungen werden verlustfrei und idempotent um Titel und zuletzt
verwendeten Provider migriert.
### Security ### Security
- Lokale Secret-Dateien, Laufzeitdaten, Modelle, Datenbanken und Logs werden von Git ausgeschlossen. - Lokale Secret-Dateien, Laufzeitdaten, Modelle, Datenbanken und Logs werden von Git ausgeschlossen.
- Das Sprachmodell darf keine Secret-Werte erhalten. - Das Sprachmodell darf keine Secret-Werte erhalten.
- Gemini bleibt ohne lokale Aktivierung, Key, Nullkostenmodus und bestätigtes
Nicht-Billing deaktiviert; Javis aktiviert niemals Billing.
- Cloudkontext wird lokal klassifiziert und minimiert; 429, Netzwerk-, Timeout-,
Key- und Modellfehler fallen auf Ollama zurück.
- Gesundheits-, Symptom-, Verletzungs-, Medikamenten-, Diagnose-, Behandlungs-
und medizinische Notfallfragen werden vor Cloudclient-Erstellung strikt lokal behandelt.
- Gemini-/Datenschutzablauf wurde am 30.07.2026 zusätzlich von Pascal manuell
erfolgreich abgenommen; kein Keywert erschien in der Ausgabe.
+26 -14
View File
@@ -4,34 +4,46 @@ Javis ist ein langfristiges, lokal-first ausgerichtetes Projekt für einen pers
## Aktueller Stand ## Aktueller Stand
Der erste lokale Textchat funktioniert auf Windows mit Ollama und `qwen3:8b`. Der lokale Textchat funktioniert auf Windows mit Ollama und `qwen3:8b`.
Er bietet echte lokale Modellantworten ohne Cloud-Fallback, neue und fortsetzbare Ein optionaler Gemini-Free-Provider ist auf einem Feature-Branch vorbereitet;
Sitzungen sowie SQLite-Persistenz außerhalb des Repositories. Datenschutzrouter, Nullkostenmodus und lokales Fallback sind automatisiert
getestet, aber noch nicht von Pascal mit einem echten Key abgenommen.
Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile
Client und Living Mind. Client und Living Mind.
## Lokaler Start ## Einfacher Start
Ollama läuft in einem eigenen Terminal ausschließlich auf localhost: Aus dem Repository:
```powershell ```powershell
$env:OLLAMA_MODELS = 'D:\Javis-Data\ollama-models' powershell -ExecutionPolicy Bypass -File .\scripts\start-javis.ps1
$env:OLLAMA_HOST = '127.0.0.1:11434'
& 'D:\Javis-Tools\ollama\ollama.exe' serve
``` ```
Der Chat startet aus dem Repository in einem zweiten Terminal: Das Skript findet die portablen Werkzeuge standardmäßig relativ zum Laufwerk des
Repositories, setzt Variablen nur für seinen Prozess, prüft Ollama ausschließlich
auf `127.0.0.1:11434`, startet es bei Bedarf versteckt und öffnet den Chat. Es
ändert weder PATH, Registry, Autostart, Windows-Dienste noch die systemweite
PowerShell-Ausführungsrichtlinie.
```powershell Abweichende Pfade sind über die Skriptparameter `-UvPath`, `-OllamaPath`,
$env:UV_CACHE_DIR = 'D:\Javis-Tools\uv-cache' `-OllamaModels`, `-DataDir`, `-UvCacheDir` und `-PythonInstallDir` oder die
$env:JAVIS_DATA_DIR = 'D:\Javis-Data\runtime' entsprechenden `JAVIS_*`-Umgebungsvariablen möglich.
& 'D:\Javis-Tools\uv\uv.exe' run javis chat
```
Im Chat zeigt `/help` alle Befehle. Ohne `JAVIS_DATA_DIR` nutzt Javis einen Im Chat zeigt `/help` alle Befehle. Ohne `JAVIS_DATA_DIR` nutzt Javis einen
sicheren benutzerspezifischen Plattformstandard. sicheren benutzerspezifischen Plattformstandard.
Gemini bleibt standardmäßig aus. Die spätere lokale Einrichtung erfolgt ohne
Key in der Konfigurationsdatei:
```powershell
& 'D:\Javis-Tools\uv\uv.exe' run javis configure gemini
& 'D:\Javis-Tools\uv\uv.exe' run javis secrets set gemini
```
Der Key wird bei der zweiten Zeile verdeckt eingegeben und im
Betriebssystem-Keyring gespeichert, niemals in Git oder TOML.
## Grundregeln ## Grundregeln
- Plattformunabhängiger modularer Monolith, voraussichtlich in Python. - Plattformunabhängiger modularer Monolith, voraussichtlich in Python.
+8 -2
View File
@@ -1,5 +1,11 @@
# Konfiguration # Konfiguration
`javis.example.toml` dokumentiert ausschließlich nicht geheime Einstellungen. Für den lokalen Betrieb wird später eine ignorierte `javis.toml` erzeugt. `javis.example.toml` dokumentiert ausschließlich nicht geheime Einstellungen.
Die lokale Datei liegt standardmäßig unter `JAVIS_DATA_DIR\javis.toml` und damit
außerhalb des Repositorys. `JAVIS_CONFIG_FILE` darf alternativ einen absoluten
Pfad angeben. Umgebungsvariablen überschreiben lokale TOML-Werte.
Secret-Werte gehören nicht in TOML, Code oder Git. Die bevorzugte Zielarchitektur verwendet einen Secret-Provider, beispielsweise Betriebssystem-Keyring, geschützte Umgebungsvariablen oder später Docker Secrets. Falls Pascal XML nutzt, liegt die echte Datei nur lokal, mit restriktiven Rechten und Git-Ausschluss. Das Modell erhält niemals ihren Inhalt. Secret-Werte gehören nicht in TOML, Code oder Git. Der Gemini-Key liegt im
Betriebssystem-Keyring und wird mit `javis secrets` verwaltet. Die TOML-Datei
enthält nur die lokale Bestätigung, dass Pascal das Google-Projekt ohne Billing
betreibt. Javis kann den externen Billingstatus nicht technisch verifizieren.
+15 -20
View File
@@ -1,25 +1,20 @@
# Entwurf für eine spätere TOML-Konfiguration.
# Der erste Chat liest die in .env.example genannten Umgebungsvariablen.
[app]
display_name = "Javis"
language = "de"
[paths]
data_dir = ""
[providers] [providers]
active = "ollama" active = "auto"
[providers.ollama]
model = "qwen3:8b" model = "qwen3:8b"
ollama_base_url = "http://127.0.0.1:11434" base_url = "http://127.0.0.1:11434"
timeout_seconds = 180 timeout_seconds = 180
cloud_fallback = false
[obsidian] [providers.gemini]
enabled = false enabled = false
vault_path = "" model = "gemini-3.6-flash"
write_areas = [] optional_model = "gemini-3.5-flash-lite"
free_only = true
[security] billing_confirmed_disabled = false
secrets_provider = "none" max_requests_per_day = 25
require_approval_for_writes = true max_input_chars = 12000
max_output_tokens = 1024
timeout_seconds = 30
max_attempts = 2
max_context_messages = 6
+13 -10
View File
@@ -8,12 +8,12 @@ Javis beginnt als modularer Monolith. Module besitzen klare Zuständigkeiten und
1. Interface: implementierte CLI; später möglicherweise Sprache, Mobilzugriff und Living Mind. 1. Interface: implementierte CLI; später möglicherweise Sprache, Mobilzugriff und Living Mind.
2. Core: implementierter Gesprächsablauf und Sitzungslogik. 2. Core: implementierter Gesprächsablauf und Sitzungslogik.
3. Providers: kleine Schnittstelle und implementierter lokaler Ollama-Adapter. 3. Providers: kleine Schnittstelle, lokaler Ollama- und optionaler Gemini-Adapter.
4. Memory: implementierte SQLite-Sitzungen; ein semantischer Index ist nur geplant. 4. Memory: SQLite-Sitzungen und separates inhaltsfreies Provider-Audit.
5. Obsidian: zunächst lesender Adapter, später eng begrenzte Entwürfe. 5. Obsidian: zunächst lesender Adapter, später eng begrenzte Entwürfe.
6. Tools und Status: feste Schemas, Allowlists, Timeouts und bereinigte Ausgaben. 6. Tools und Status: feste Schemas, Allowlists, Timeouts und bereinigte Ausgaben.
7. Security: Freigaben, Secret-Provider und Berechtigungsprüfung. 7. Security: lokaler Datenschutzrouter, Freigaben und Betriebssystem-Keyring.
8. Audit: nachvollziehbare Aktionen ohne geheime Inhalte. 8. Audit: Provider-Metadaten ohne Prompts, Antworten oder geheime Inhalte.
## Plattformgrenzen ## Plattformgrenzen
@@ -24,10 +24,13 @@ Javis beginnt als modularer Monolith. Module besitzen klare Zuständigkeiten und
## Implementierter Chatfluss ## Implementierter Chatfluss
`interface.cli` nimmt Text an, `core.chat_service` verbindet Sitzungsverlauf und `interface.cli` nimmt Text an und verbindet den `core.provider_router`.
Provider, `providers.ollama` spricht nur die lokale Ollama-HTTP-API an und Dieser klassifiziert die aktuelle Anfrage vollständig lokal, minimiert den
`memory.sqlite_store` speichert erfolgreiche Benutzer-/Assistentenpaare atomar. freigegebenen Kontext und wählt Gemini oder Ollama. `core.chat_service` speichert
Scheitert das Modell, wird keine erfundene Antwort gespeichert. erst eine erfolgreiche Antwort zusammen mit der Benutzernachricht atomar.
Es existiert kein Cloudadapter, Toolaufruf, Obsidian-Zugriff oder eigener Gemini ist nur bei lokaler Aktivierung, vorhandenem Key, `free_only = true`,
öffentlicher Netzwerkdienst. Der Ollama-Endpunkt muss eine Loopback-Adresse sein. bestätigtem Nicht-Billing und passender Datenschutzentscheidung erreichbar.
Ollama bleibt Loopback-, Datenschutz-, Offline-, Quota- und Fehlerfallback.
Es existiert weiterhin kein Toolaufruf, Obsidian-Zugriff oder eigener öffentlicher
Netzwerkdienst.
+132 -42
View File
@@ -58,11 +58,57 @@ Rootserver:
- Modelle liegen außerhalb von Git unter `D:\Javis-Data\ollama-models`. - Modelle liegen außerhalb von Git unter `D:\Javis-Data\ollama-models`.
- Der lokale Ollama-Dienst bindet nur an `127.0.0.1:11434`; PATH, Registry, - Der lokale Ollama-Dienst bindet nur an `127.0.0.1:11434`; PATH, Registry,
Autostart und Windows-Dienste blieben unverändert. Autostart und Windows-Dienste blieben unverändert.
- CLI-Befehle: `/new`, `/sessions`, `/load <ID>`, `/clear`, `/help`, `/exit`. - CLI-Befehle: `/new`, `/sessions`, `/load <NR|ID>`, `/search <Text>`,
`/rename <Titel>`, `/clear`, `/provider`, `/privacy`, `/status`, `/help`, `/exit`.
- Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte - Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte
Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`. Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`.
- Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert. - Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert.
- Keine Tool-, Sprach-, Mobil-, Status- oder Living-Mind-Funktion ist implementiert. - Keine Tool-, Sprach-, Mobil- oder Living-Mind-Funktion ist implementiert.
- Offizielle Gemini-Prüfung abgeschlossen: `gemini-3.6-flash` und
`gemini-3.5-flash-lite` sind stabile GA-Modelle mit Free-Tier-Textnutzung;
Standard dieses Pakets wird ausschließlich `gemini-3.6-flash`.
- Sicherer Secret-Provider mit `keyring` 25.7.0 implementiert; unter Windows wird
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.
- 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.
- 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.
- Nicht geheime Provider-Einstellungen werden aus
`JAVIS_DATA_DIR\javis.toml` geladen; Umgebungsvariablen können sie sicher
überschreiben. Die Aktivierungsvorlage enthält `free_only = true` und die
lokale Billing-Bestätigung, aber niemals einen API-Key.
- `javis configure gemini` verlangt zwei ausdrückliche Bestätigungen und lässt
Gemini bei leerer oder negativer Antwort deaktiviert.
- Der echte CLI-Chat verwendet nun `hybrid/auto`; der Key wird erst vor einem
zulässigen Cloudaufruf gelesen. `/provider`, `/privacy` und `/status` zeigen
ausschließlich bereinigte Modi, Entscheidungen und Zähler.
- `scripts/start-javis.ps1` verifiziert das Repository, setzt nur
Prozessvariablen, prüft Ollama auf Loopback, startet es bei Bedarf versteckt
und wartet begrenzt. Pfade sind per Parameter oder `JAVIS_*` überschreibbar.
- Allgemeine unpersönliche Technikfragen zu RAM/SSD, SQLite, HTTP, Git und
ähnlichen Begriffen werden gezielter `allowed`; `never` und `ask` behalten
Vorrang bei Secrets, interner Infrastruktur und persönlichem Kontext.
- Gesundheits-, Symptom-, Blutungs-, Verletzungs-, Medikamenten-, Diagnose-,
Behandlungs- und medizinische Notfallfragen sind zwingend lokal `never`.
- `/privacy` trennt Inhaltsklassifikation, tatsächlichen Provider, Unterdrückung
durch Modus `local` und technischen Provider-Fallback.
- Ollama, Gemini, Hybridrouter, Chatservice und CLI streamen sichtbare Antwortteile.
Verdeckte Gedanken werden nicht angefordert oder ausgegeben; Terminal-Markdown
wird lesbar bereinigt. Strg+C verwirft Teilantworten atomar und kehrt zum Chat zurück.
## Aktuelle Architektur ## Aktuelle Architektur
@@ -70,10 +116,13 @@ Rootserver:
- plattformunabhängige Pfade und zentrale Konfiguration - plattformunabhängige Pfade und zentrale Konfiguration
- Modell-Provider als kleine austauschbare Schnittstelle - Modell-Provider als kleine austauschbare Schnittstelle
- Ollama-Provider akzeptiert nur lokale HTTP-Loopback-Adressen - Ollama-Provider akzeptiert nur lokale HTTP-Loopback-Adressen
- SQLite-Sitzungsspeicher mit atomaren Benutzer-/Assistentenpaaren - SQLite-Sitzungsspeicher mit atomaren Benutzer-/Assistentenpaaren, neutralem
Starttitel, lokaler sicherer Titelerzeugung und zuletzt genutztem Provider
- Streaming wird erst nach vollständigem Abschluss atomar gespeichert; bei Abbruch
bleibt weder die Benutzerfrage noch eine unvollständige Antwort im Verlauf.
- Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard - Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard
- aktuell ausschließlich lokales Modell; als separates Folgepaket ist Gemini als - Hybridprovider bevorzugt Gemini für cloudgeeignete Inhalte und verwendet
bevorzugter kostenloser Provider für ausdrücklich cloudgeeignete Inhalte geplant Ollama bei Datenschutz, Ablehnung, Offline-, Quota- und Providerfehlern
- strikter Nullkostenmodus ohne Billing-Aktivierung oder kostenpflichtigen Fallback; - strikter Nullkostenmodus ohne Billing-Aktivierung oder kostenpflichtigen Fallback;
lokales Modell bleibt Offline-, Datenschutz-, Ausfall- und Quota-Fallback lokales Modell bleibt Offline-, Datenschutz-, Ausfall- und Quota-Fallback
- Secret-Werte außerhalb von Code, Git, Prompts und Logs - Secret-Werte außerhalb von Code, Git, Prompts und Logs
@@ -104,6 +153,28 @@ Rootserver:
- Echten CLI-Chat ausgeführt und dieselbe Sitzung in einem neuen Prozess geladen, - Echten CLI-Chat ausgeführt und dieselbe Sitzung in einem neuen Prozess geladen,
aufgelistet und erfolgreich mit einer weiteren Modellantwort fortgesetzt. aufgelistet und erfolgreich mit einer weiteren Modellantwort fortgesetzt.
- Obsidian, Ubuntu-Laptop und Rootserver blieben unverändert. - Obsidian, Ubuntu-Laptop und Rootserver blieben unverändert.
- Aktuelle offizielle Gemini-Modelle, Free-Tier-Preise, dynamische Quoten,
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.
- 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.
- Lokalen TOML-Loader und sicheren Erzeuger ergänzt; vorhandene Konfigurationen
werden nicht ungefragt überschrieben und fehlerhafte TOML wird abgelehnt.
- Hybridprovider in die CLI verdrahtet, sicheren Aktivierungsdialog und
bereinigte Provider-/Datenschutz-/Statusbefehle ergänzt.
- Lesender Ollama-Status prüft nur Loopback-Erreichbarkeit und Modellliste.
- Sicheres PowerShell-Startskript und kompakte Startdokumentation ergänzt; keine
PATH-, Registry-, Autostart-, Dienst- oder Richtlinienänderung.
- Medizinische Inhalte vor Cloudclient-Erstellung strikt auf `never` gesetzt;
bestehende SQLite-Daten transaktional und wiederholbar um Titel/Provider migriert.
- Echtes Ollama-/Gemini-Streaming, providerübergreifendes Fallback vor der ersten
Ausgabe und sicheren Streaming-Abbruch nach Teilausgabe implementiert.
- CLI-Streaming mit lesbarer Markdown-Bereinigung und sicherem Strg+C ergänzt.
## Aktuelle Tests ## Aktuelle Tests
@@ -118,9 +189,18 @@ Letzter bestätigter Projektstand:
- uv-Lock und `uv sync --dev`: bestanden - uv-Lock und `uv sync --dev`: bestanden
- Python in `.venv`: 3.12.13 - Python in `.venv`: 3.12.13
- Ruff in `.venv`: 0.16.0 - Ruff in `.venv`: 0.16.0
- Unit-Tests: 13 bestanden - Unit-Tests: 91 bestanden; Navigation, Streaming, Migration, Datenschutz,
ID-Präfixe, begrenzte Suche und SQL-Sonderzeichen abgedeckt
- PowerShell-Syntax des Startskripts: erfolgreich geparst
- Ruff Lint: bestanden - Ruff Lint: bestanden
- Ruff Formatprüfung: bestanden - Ruff Formatprüfung: bestanden
- lokaler CLI-Smoke ohne Secretzugriff: bestanden
- echter Ollama-CLI-Streaming-Smoke mit Neustart/Laden: bestanden
- echter Gemini-Streaming-Smoke mit öffentlichem Inhalt: bestanden; ein Abschnitt,
20 sichtbare Zeichen, 14 Eingabe- und 4 Ausgabetoken; kein Keywert ausgegeben
- Obsidian-Vault unverändert: 1 Markdown-, 11 Gesamtdateien, letzter
Schreibzeitpunkt weiterhin 30.07.2026 15:42:31
- Rootserver und Ubuntu-Laptop wurden nicht kontaktiert
Lokaler Modell-Smoke-Test: Lokaler Modell-Smoke-Test:
@@ -138,42 +218,57 @@ CLI-/Persistenz-Smoke:
- `/sessions` listete Provider, Modell und Nachrichtenanzahl korrekt - `/sessions` listete Provider, Modell und Nachrichtenanzahl korrekt
- fortgesetzte Frage erhielt die echte lokale Antwort `4` - fortgesetzte Frage erhielt die echte lokale Antwort `4`
Abnahmestatus:
- automatisiert durch Codex getestet: Core-Chat, SQLite, CLI und lokales Modell
- manuell durch Pascal getestet: am 30.07.2026 erfolgreich; CLI-Start, echte
deutsche Antworten, Sitzungskontext, `/sessions`, `/exit`, Neustart,
`/load <UUID>`, vollständiger Verlauf und Fortsetzung
- Gemini/Datenschutz/Fallback automatisiert durch Codex getestet: ja
- Gemini/Datenschutz/Fallback manuell durch Pascal getestet: am 30.07.2026;
verdeckte Key-Speicherung ohne Wertausgabe, vollständiger `/status`, echter
Gemini-Erfolg, Cloudzähler, `ask`-Ablehnung, `never`, `/provider local` und
Startskript erfolgreich
- für Merge freigegeben: ja, nach den dokumentierten kleinen Korrekturen
- für produktive Nutzung freigegeben: nicht separat erklärt; keine Freigabe für
spätere Tools, Obsidian oder autonome Aktionen
## Git-Stand ## Git-Stand
- aktueller Branch: `main` - aktueller Arbeitsbranch: `feat/chat-comfort`
- stabiler Branch: `main` - stabiler Ausgangsstand: `main` bei `77b510b`
- Grundgerüstcommit: `6d04171` - medizinischer Datenschutz-Fix: `9148193`
- letzter Commit vor der Handoff-Verdichtung: `0e82748` - Provider- und CLI-Streaming: `00a5a6c`
- Handoff-Verdichtung auf `main`: `e6eb995` - SQLite-Titel und Migration: `112ae6d`
- Python-Entwicklungsumgebung auf dem Feature-Branch: `fa90a5c` - Sitzungsnavigation: `43c1f76`
- Laufzeit-/Modelldokumentation: `10d7cd7` - Feature-Branch ist als `7a14fe8` zu
- Chatkern und CLI: `ec9b2f9` `origin/feat/gemini-privacy-router` gepusht
- Unit-Tests: `b153daa` - konfliktfreier Merge nach `main`: `564a3fd`
- Feature-Dokumentation: `5d567e5` - 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
- lokaler Merge nach `main`: `f3d6dfb` - `main` erfolgreich zu `origin/main` gepusht; Remote-Tracking ist synchron
- geprüfter und gepushter Abschlussstand vor diesem Handoff-Update: `0a550e6` - Secret-Provider: `570e7fa`
- Datenschutz/Nullkosten: `286d96d`
- isolierter Gemini-Provider: `f3e1183`
- Hybridrouter: `e57186f`
- lokale Konfiguration: `ab6ea48`
- CLI-Verdrahtung: `af9426e`
- Startskript: `141ff2e`
- Resttests: `a0af15a`
- Routing-/Reporting-Korrektur: `7a14fe8`
- Gemini-/Datenschutz-Merge nach `main`: `564a3fd`
- `origin` verwendet HTTPS - `origin` verwendet HTTPS
- kein Force-Push und keine umgeschriebene Historie - kein Force-Push und keine umgeschriebene Historie
## Kompakter Changelog
- `49a1dd3`: erster Gitea-Verbindungstest
- `6d04171`: sicheres Javis-Grundgerüst
- `0e82748`: Werkzeug-, Token- und Handoff-Regeln
- `e6eb995`: einmalige Archivierung und Verdichtung des Handoffs
- aktuell: isolierte Python-Toolchain und reproduzierbare Entwicklungsumgebung
- aktuell: portable Ollama-Laufzeit und `qwen3:8b` außerhalb von Git verifiziert
- aktuell: lokaler CLI-Chat und persistente SQLite-Sitzungen vollständig abgenommen
## Offene Entscheidungen und Fehler ## Offene Entscheidungen und Fehler
- endgültiger Produkt-/Repositoryname bleibt offen - endgültiger Produkt-/Repositoryname bleibt offen
- Startmechanismus für Ollama ist noch bewusst manuell und nur pro Prozess konfiguriert - Startskript startet Ollama bei Bedarf pro Prozess; kein Autostart oder Dienst
- Antworten werden noch nicht gestreamt; Sitzungen besitzen noch keine Titel oder Suche - Antworten werden gestreamt; Titel, Navigation und Suche sind implementiert
- genauer späterer Obsidian-Schreibbereich ist nicht freigegeben - 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
- aktuelle kostenlose Gemini-Modelle, Modellkennungen und Kontingente müssen vor Klartext-XML wird nicht für API-Schlüssel verwendet
der Implementierung anhand offizieller Google-Dokumentation neu geprüft werden - 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 - Gaming-PC zeigt derzeit nur 8 statt 16 logische CPU-Prozessoren; Ursache ungeklärt
- ASUS-Laptop ist nicht automatisch für dasselbe Modell wie die RTX 3060 geeignet - ASUS-Laptop ist nicht automatisch für dasselbe Modell wie die RTX 3060 geeignet
@@ -192,15 +287,10 @@ CLI-/Persistenz-Smoke:
## Nächster sinnvoller Auftrag ## Nächster sinnvoller Auftrag
Als separates nächstes Featurepaket auf neuem Branch: Auf `feat/chat-comfort` als Nächstes Dokumentation committen, Branch pushen,
nach `main` mergen, dort erneut testen und `main` pushen.
1. offizielle aktuelle Gemini-Free-Tier-Modelle und Kontingente neu prüfen Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
2. Secret-Provider ohne Schlüsselzugriff des Modells implementieren Noch keine Obsidian-Integration oder Tools beginnen.
3. Datenschutzrouting `allowed`/`ask`/`never` vor jedem Cloudaufruf erzwingen
4. Gemini als kostenlosen bevorzugten Provider und Ollama als lokalen
Datenschutz-/Offline-/429-Fallback implementieren
5. `free_only = true`, keine Billing-Aktivierung und alle geforderten Erfolgs-,
Key-, 429-, Netzwerk-, Fallback- und Datenschutztests nachweisen
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen. Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
+2 -1
View File
@@ -10,7 +10,8 @@
| ADR-0006 | Ollama und qwen3:8b für den ersten Windows-Chat verwenden | angenommen | | ADR-0006 | Ollama und qwen3:8b für den ersten Windows-Chat verwenden | angenommen |
| ADR-0007 | Gemini kostenlos bevorzugen, sensible Inhalte lokal halten und niemals Billing aktivieren | angenommen | | ADR-0007 | Gemini kostenlos bevorzugen, sensible Inhalte lokal halten und niemals Billing aktivieren | angenommen |
Offen bleiben insbesondere das endgültige Runtime-Secret-System, der genaue Der normale Runtime-Secret-Speicher ist mit dem Betriebssystem-Keyring festgelegt;
keine XML-Secret-Datei. Offen bleiben insbesondere der genaue
Obsidian-Schreibbereich, das spätere Laptop-Modell und der Produktname. Obsidian-Schreibbereich, das spätere Laptop-Modell und der Produktname.
Ausführliche Begründungen stehen unter [`docs/adr`](adr/README.md). Ausführliche Begründungen stehen unter [`docs/adr`](adr/README.md).
+18 -10
View File
@@ -35,19 +35,27 @@ $env:UV_CACHE_DIR = 'D:\Javis-Tools\uv-cache'
& 'D:\Javis-Tools\uv\uv.exe' run ruff format --check . & 'D:\Javis-Tools\uv\uv.exe' run ruff format --check .
``` ```
Vor dem Chat wird Ollama in einem eigenen Terminal gestartet: ## Komfortabler Start
Der normale Start aus dem Repository benötigt nur:
```powershell ```powershell
$env:OLLAMA_MODELS = 'D:\Javis-Data\ollama-models' powershell -ExecutionPolicy Bypass -File .\scripts\start-javis.ps1
$env:OLLAMA_HOST = '127.0.0.1:11434'
& 'D:\Javis-Tools\ollama\ollama.exe' serve
``` ```
Danach: `-ExecutionPolicy Bypass` gilt nur für diesen Prozess; das Skript ändert keine
systemweite Richtlinie. Es prüft Ollama auf Loopback, startet bei Bedarf die
portable Laufzeit versteckt und wartet höchstens 15 Sekunden. Werkzeug- und
Datenpfade können mit Parametern oder folgenden Umgebungsvariablen überschrieben
werden:
```powershell - `JAVIS_UV_PATH`
$env:JAVIS_DATA_DIR = 'D:\Javis-Data\runtime' - `JAVIS_OLLAMA_PATH`
& 'D:\Javis-Tools\uv\uv.exe' run javis chat - `JAVIS_OLLAMA_MODELS`
``` - `JAVIS_DATA_DIR`
- `JAVIS_UV_CACHE_DIR`
- `JAVIS_PYTHON_INSTALL_DIR`
Die absoluten Pfade sind ausschließlich lokale Entwicklungsdokumentation. Der Anwendungscode darf keine festen Windows-Pfade enthalten. Die Standardpfade werden vom Laufwerk des Repositorys abgeleitet; persönliche
Windows-Benutzerpfade sind nicht eingebrannt. PATH, Registry, Autostart und
Windows-Dienste bleiben unverändert.
+85
View File
@@ -0,0 +1,85 @@
# Gemini-Provider: bestätigte Rahmenbedingungen
Stand der Prüfung: 30.07.2026. Verwendet wurden ausschließlich offizielle
Google-Dokumentation und das offizielle Google-SDK-Repository.
## Modellauswahl
- aktiver Standard dieses Arbeitspakets: `gemini-3.6-flash`
- später optional konfigurierbar: `gemini-3.5-flash-lite`
- beide Kennungen sind stabile, allgemein verfügbare Modelle
- keine Preview-, Experimental- oder `latest`-Kennung wird Standard
`gemini-3.6-flash` ist für Standard-Textgenerierung im Free Tier mit kostenfreien
Ein- und Ausgabetokens aufgeführt. `gemini-3.5-flash-lite` ist ebenfalls im Free
Tier verfügbar, wird in diesem Paket aber nicht aktiv geroutet.
Quellen:
- <https://ai.google.dev/gemini-api/docs/models/gemini-3.6-flash>
- <https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite>
- <https://ai.google.dev/gemini-api/docs/pricing>
- <https://ai.google.dev/gemini-api/docs/deprecations>
## Quoten und Kosten
Google misst Limits unter anderem als Requests pro Minute, Eingabetokens pro
Minute und Requests pro Tag. Die aktiven Werte hängen von Modell, Projekt und
Nutzungsstufe ab, sind nicht garantiert und müssen in Google AI Studio geprüft
werden. Javis bildet deshalb keine Google-Quote als feste Wahrheit ab, sondern
setzt zusätzliche konservative lokale Schutzlimits.
Der Free Tier erfordert kein Billingkonto. Der Wechsel in einen bezahlten Tier
erfordert laut Google eine aktive Billing-Verknüpfung. Javis kann den externen
Projektstatus nicht technisch beweisen und verlangt deshalb vor Aktivierung eine
lokale Bestätigung, dass Billing deaktiviert ist.
Quellen:
- <https://ai.google.dev/gemini-api/docs/rate-limits>
- <https://ai.google.dev/gemini-api/docs/billing>
## Datenschutz
Die Preisübersicht kennzeichnet kostenlose Standardnutzung grundsätzlich als für
Produktverbesserung nutzbar. Die zusätzlichen Nutzungsbedingungen enthalten eine
Sonderregel für EWR, Schweiz und Vereinigtes Königreich. Unabhängig davon bleibt
Javis konservativ: keine Secrets, Gesundheits-, Finanz- oder besonders sensiblen
Daten; persönliche Daten nur nach lokaler Freigabe und mit minimalem Kontext.
Quellen:
- <https://ai.google.dev/gemini-api/terms>
- <https://ai.google.dev/gemini-api/docs/logs-policy>
## Schlüssel und SDK
Neue Schlüssel aus Google AI Studio sind laut aktueller Dokumentation Auth-Keys,
standardmäßig auf die Gemini API beschränkt. Unbeschränkte Standard-Keys werden
abgelehnt beziehungsweise ausgemustert. Pascal soll einen neuen, ausschließlich
für Javis verwendeten Auth-Key in AI Studio erstellen und niemals in Chat, Git,
Obsidian, Logs oder Dokumentation einfügen.
Für Python wird das GA-SDK `google-genai` verwendet. Javis nutzt ausschließlich
zustandslose Textgenerierung über `generate_content`; keine Chatspeicherung,
Dateien, Tools, Grounding, Caches, Agenten oder Medienfunktionen.
Der reproduzierbare Lockfile löst aktuell `google-genai` 2.16.0 auf.
Quellen:
- <https://ai.google.dev/gemini-api/docs/api-key>
- <https://ai.google.dev/gemini-api/docs/libraries>
- <https://ai.google.dev/gemini-api/docs/generate-content/text-generation>
- <https://github.com/googleapis/python-genai>
## Fehler und Retries
Timeouts und geeignete Server-/Netzwerkfehler dürfen höchstens einmal kontrolliert
wiederholt werden. `429 RESOURCE_EXHAUSTED`, Authentifizierungs-, Datenschutz- und
Quota-Fehler werden nicht wiederholt. Anschließend wird im späteren Router sichtbar
lokal weitergearbeitet. Das SDK besitzt eigene Retry-Mechanismen; Javis konfiguriert
diese ausdrücklich auf höchstens zwei Gesamtversuche und schließt 429 aus.
Quelle:
- <https://ai.google.dev/gemini-api/docs/troubleshooting>
+17 -21
View File
@@ -1,30 +1,26 @@
# Nächste Sitzung # Nächste Sitzung
## Ziel ## Ausgangsstand
Nächstes separates Featurepaket: kostenlose Gemini-Anbindung mit strikt lokalem Der Core-Chat, Gemini-/Datenschutz-Meilenstein und das Chatkomfort-Paket sind
Datenschutzrouting und Ollama als Ausfall-, Quota-, Offline- und Sensitivitätsfallback. automatisiert geprüft. Pascal hat Core-Chat sowie Gemini/Datenschutz am
30.07.2026 manuell abgenommen.
## Vor Änderungen erneut prüfen Chatkomfort umfasst sicheres Ollama-/Gemini-Streaming, Strg+C ohne Teilpersistenz,
medizinisches `never`, automatische und manuelle Sitzungstitel, nummerierte
Listen, Laden per Nummer/ID-Präfix und begrenzte Suche in Titeln/Benutzertexten.
1. `git status --short --branch` 91 Unit-Tests, Ruff, Struktur-, Secret-, Gitignore- und Auditprüfungen sowie
2. `git log -1 --oneline` echte Ollama- und Gemini-Streaming-Smokes sind erfolgreich.
3. `docs/PROJECT_STATUS.md`
4. offene Entscheidungen in `docs/DECISIONS.md`
## Vorgeschlagener Umfang ## Nächster sinnvoller Schritt
- aktuelle kostenlose Gemini-Modelle, Kennungen und Kontingente ausschließlich in Zuerst nur den Git-Abschluss des Chatkomfort-Branches prüfen beziehungsweise
offizieller Google-Dokumentation prüfen; Kandidaten nicht ungeprüft übernehmen den finalen Stand aus `docs/CHATGPT_HANDOFF.md` übernehmen. Danach mit Pascal
- Providerrouter mit `free_only = true` und ohne Billing-Konfiguration entwerfen ein einzelnes nächstes Paket festlegen; noch keine Folgephase automatisch beginnen.
- lokale Datenschutzentscheidung `allowed`, `ask` oder `never` vor jedem Cloudaufruf
- Gemini-Key nur über lokalen Secret-Provider; Wert niemals an Modell, Git,
Obsidian, Logs, Dokumentation oder Chat geben
- Gemini-Erfolg, fehlenden/ungültigen Key, 429, Netzwerkausfall, lokalen Fallback,
`never`, `ask` und ausbleibende Billing-Aktivierung testen
## Nicht beginnen ## Weiterhin nicht beginnen
Keinen echten Schlüssel anfordern oder dokumentieren, kein Billingkonto verbinden, Noch keine Obsidian-Integration, Tools, SSH-/Serversteuerung, Sprache, Mobile
keine kostenpflichtigen Fallbacks, keinen vollständigen Vault übertragen und Client oder Living Mind. Der vorhandene Gemini-Key bleibt im Betriebssystem-
weiterhin kein Obsidian-Schreiben, SSH-/Serverzugriff, Sprache oder Living Mind. Keyring und darf nicht erneut in Chat, Logs oder Dokumentation erscheinen.
+82 -17
View File
@@ -1,6 +1,6 @@
# Projektstatus # Projektstatus
Stand: lokaler Textchat auf `main`, 30.07.2026. Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026.
## Erreicht ## Erreicht
@@ -20,32 +20,97 @@ Stand: lokaler Textchat auf `main`, 30.07.2026.
- zwei echte deutsche Modellantworten erfolgreich vollständig auf der RTX 3060 erzeugt - zwei echte deutsche Modellantworten erfolgreich vollständig auf der RTX 3060 erzeugt
- kalter Test 36,19 s, warmer Kurztest 0,56 s; geladene GPU-Belegung 6.842 MiB - kalter Test 36,19 s, warmer Kurztest 0,56 s; geladene GPU-Belegung 6.842 MiB
- lokaler Ollama-Provider, zentrale Konfiguration und SQLite-Sitzungsspeicher implementiert - lokaler Ollama-Provider, zentrale Konfiguration und SQLite-Sitzungsspeicher implementiert
- CLI mit `/new`, `/sessions`, `/load`, `/clear`, `/help` und `/exit` implementiert - CLI streamt Antworten und unterstützt `/new`, nummerierte `/sessions`,
`/load <NR|ID>`, `/search`, `/rename`, `/clear`, `/help` und `/exit`
- bestehende SQLite-Datenbanken werden automatisch, transaktional und
wiederholbar um Sitzungstitel und zuletzt verwendeten Provider migriert
- lokale Titelbildung ohne Modellaufruf; offensichtliche Secrets und medizinische
Inhalte werden nicht wörtlich in automatische Titel übernommen
- echte CLI-Modellantwort sowie Laden und Fortsetzen derselben Sitzung in einem - echte CLI-Modellantwort sowie Laden und Fortsetzen derselben Sitzung in einem
neuen Prozess bestätigt neuen Prozess bestätigt
- 13 Unit-Tests, Ruff und Strukturprüfung erfolgreich - 91 Unit-Tests, Ruff, Struktur-, Gitignore-, Secret- und Auditprüfung erfolgreich
- lokaler Core-Chat am 30.07.2026 zusätzlich von Pascal manuell erfolgreich
geprüft: deutsche Antworten, Sitzungskontext, `/sessions`, `/exit`, Neustart,
`/load <UUID>`, vollständiger Verlauf und Fortsetzung
## Abnahmestatus
- Core-Chat automatisiert durch Codex getestet: ja
- Core-Chat manuell durch Pascal getestet: ja, am 30.07.2026
- Gemini/Datenschutz/Fallback automatisiert durch Codex getestet: ja
- Gemini/Datenschutz/Fallback manuell durch Pascal getestet: ja, am 30.07.2026
- für Merge freigegeben: ja, nach den zwei kleinen Korrekturen
- für produktive Nutzung freigegeben: nicht separat erklärt; keine spätere Tool-,
Obsidian- oder Autonomiefunktion
## Chatkomfort-Meilenstein
- Branch `feat/chat-comfort`
- Ollama und Gemini verwenden denselben optionalen Streamingvertrag; Provider
ohne Streaming bleiben kompatibel
- keine versteckten Gedanken; Terminal-Markdown wird lesbar bereinigt
- vollständige Antwort wird genau einmal gespeichert; Strg+C und Streamfehler
hinterlassen keine unvollständige Nachricht
- medizinische Fragen werden vor jeder Cloudclient-Erstellung zwingend `never`
- Sitzungsliste zeigt Nummer, kurze ID, Titel, Datum, Nachrichtenanzahl und Provider
- Laden per letzter Listennummer oder eindeutigem ID-Präfix; Mehrdeutigkeit wird erklärt
- Suche ist auf 20 Ergebnisse begrenzt, parametrisiert und durchsucht nur Titel
sowie Benutzernachrichten
- echter Ollama-CLI-Streaming-/Neustart-Smoke und echter kleiner
Gemini-Streaming-Smoke mit öffentlichem Inhalt erfolgreich
## Gemini-/Datenschutz-Meilenstein
- Branch `feat/gemini-privacy-router`
- 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`
- 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
- isolierter Gemini-Provider mit offiziellem `google-genai` 2.16.0, API `v1`,
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
- 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
- lokale nicht geheime TOML-Konfiguration außerhalb von Git; konservativer
Erzeuger überschreibt keine vorhandene Datei
- `javis configure gemini` mit doppelter ausdrücklicher Bestätigung
- echter Chat auf Hybridprovider verdrahtet; Key wird verzögert geladen
- `/provider`, `/privacy` und `/status` geben keine Inhalte oder Keywerte aus
- allgemeine unpersönliche Technikbegriffe werden gezielter `allowed`, während
persönliche, interne oder geheime technische Inhalte `ask`/`never` bleiben
- `/privacy` trennt lokalen Modus von einem technischen Provider-Fallback
- sicheres `scripts/start-javis.ps1` mit relativer Repositoryprüfung,
Loopback-Ollama und überschreibbaren externen Pfaden
- 91 Unit-Tests aktuell erfolgreich; PowerShell-Syntax, Lockfile, Sync,
Strukturprüfung, Secret-Scan, Gitignore, Ruff-Format und Lint sauber
## Nicht implementiert ## Nicht implementiert
Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client, Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client,
Living-Mind-Frontend und automatische Ollama-Prozessverwaltung. Living-Mind-Frontend.
## Git ## Git
- Branch: `main` - aktueller Branch: `feat/chat-comfort`
- Grundgerüstcommit: `6d04171` - Basis: `main` bei `77b510b`
- Basis des Feature-Branches: `e6eb995` - medizinisches Routing: `9148193`
- Toolchain: `fa90a5c` - Streaming: `00a5a6c`
- Laufzeit/Modell: `10d7cd7` - Titelmigration: `112ae6d`
- Chatkern: `ec9b2f9` - Sitzungsnavigation: `43c1f76`
- Tests: `b153daa` - vorheriger Gemini-/Datenschutz-Merge auf `main`: `564a3fd`
- Dokumentation: `5d567e5`
- Merge nach `main`: `f3d6dfb`
## Bekannte Einschränkung ## Bekannte Einschränkung
Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werden Das Startskript startet Ollama bei Bedarf lokal. Eine Löschfunktion ist nicht
noch nicht gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion. Bestandteil dieses Pakets.
`qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet. `qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet.
Gemini ist als separates kostenloses Folgepaket mit lokalem Datenschutzrouting Der Gemini-Key liegt ausschließlich im Betriebssystem-Keyring; sein Wert wurde
geplant, aber noch nicht implementiert oder mit einem API-Key konfiguriert. von Codex weder gelesen noch ausgegeben. Pascal hat den echten Cloud- und
Fallbackablauf erfolgreich geprüft.
+14 -1
View File
@@ -14,10 +14,23 @@
Secrets dürfen nie in Code, Git, Prompts, Chatverläufe oder Logs gelangen. Das Modell erfährt nur den Namen, die Verfügbarkeit und ein bereinigtes Aktionsergebnis. Secrets dürfen nie in Code, Git, Prompts, Chatverläufe oder Logs gelangen. Das Modell erfährt nur den Namen, die Verfügbarkeit und ein bereinigtes Aktionsergebnis.
Bevorzugt wird eine Secret-Provider-Abstraktion. Eine optionale lokale XML-Datei wie `key-pw.xml` oder `secrets.local.xml` ist nur als restriktiv geschützte, Git-ignorierte Klartextdatei zulässig. Ins Repository darf höchstens eine leere Beispielstruktur. Die Secret-Provider-Abstraktion verwendet im normalen Betrieb das
Betriebssystem-Keyring, unter Windows den Credential Manager. Der Gemini-Key wird
verdeckt eingegeben, nie ausgegeben und erst unmittelbar vor einem zulässigen
Cloudaufruf gelesen. Für API-Keys wird keine XML-Secret-Datei verwendet.
Eine vorhandene SSH-Schlüsseldatei aus früherer LS25-/Gitea-Arbeit bleibt lokal. Pfad, privater Inhalt und Gerätekennung werden nicht dokumentiert. Eine vorhandene SSH-Schlüsseldatei aus früherer LS25-/Gitea-Arbeit bleibt lokal. Pfad, privater Inhalt und Gerätekennung werden nicht dokumentiert.
## Cloud und Nullkostenmodus
- Datenschutzklassifizierung `allowed`, `ask` oder `never` erfolgt lokal.
- `ask` ist ohne ausdrückliches Ja abgelehnt; `never` ist nicht übersteuerbar.
- Prompts werden auf den minimal freigegebenen Kontext begrenzt.
- Gemini bleibt ohne Aktivierung, Key und lokale Nicht-Billing-Bestätigung aus.
- Javis aktiviert kein Billing und nutzt keine kostenpflichtigen Zusatzfunktionen.
- Die externe Billingfreiheit kann Javis nicht technisch garantieren; Pascal muss
sie in Google AI Studio beziehungsweise dem Google-Projekt selbst prüfen.
## Obsidian ## Obsidian
- gesamter Vault zunächst nur lesend - gesamter Vault zunächst nur lesend
+4 -1
View File
@@ -8,7 +8,10 @@ version = "0.0.0"
description = "Safe, local-first personal assistant project scaffold" description = "Safe, local-first personal assistant project scaffold"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = [
"google-genai>=2.6,<3",
"keyring>=25.7,<26",
]
[project.scripts] [project.scripts]
javis = "javis.interface.cli:main" javis = "javis.interface.cli:main"
+121
View File
@@ -0,0 +1,121 @@
[CmdletBinding()]
param(
[string]$UvPath,
[string]$OllamaPath,
[string]$OllamaModels,
[string]$DataDir,
[string]$UvCacheDir,
[string]$PythonInstallDir
)
$ErrorActionPreference = 'Stop'
function Get-ConfiguredPath {
param(
[string]$ParameterValue,
[string]$EnvironmentName,
[string]$DefaultValue
)
if (-not [string]::IsNullOrWhiteSpace($ParameterValue)) {
return [System.IO.Path]::GetFullPath($ParameterValue)
}
$environmentValue = [System.Environment]::GetEnvironmentVariable($EnvironmentName)
if (-not [string]::IsNullOrWhiteSpace($environmentValue)) {
return [System.IO.Path]::GetFullPath($environmentValue)
}
return [System.IO.Path]::GetFullPath($DefaultValue)
}
function Test-OllamaReady {
try {
$null = Invoke-RestMethod `
-Uri 'http://127.0.0.1:11434/api/version' `
-Method Get `
-TimeoutSec 2
return $true
}
catch {
return $false
}
}
$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot 'pyproject.toml') -PathType Leaf)) {
throw "Das Repository konnte relativ zum Startskript nicht verifiziert werden."
}
$driveRoot = [System.IO.Path]::GetPathRoot($repositoryRoot)
$resolvedUv = Get-ConfiguredPath `
-ParameterValue $UvPath `
-EnvironmentName 'JAVIS_UV_PATH' `
-DefaultValue (Join-Path $driveRoot 'Javis-Tools\uv\uv.exe')
$resolvedOllama = Get-ConfiguredPath `
-ParameterValue $OllamaPath `
-EnvironmentName 'JAVIS_OLLAMA_PATH' `
-DefaultValue (Join-Path $driveRoot 'Javis-Tools\ollama\ollama.exe')
$resolvedModels = Get-ConfiguredPath `
-ParameterValue $OllamaModels `
-EnvironmentName 'JAVIS_OLLAMA_MODELS' `
-DefaultValue (Join-Path $driveRoot 'Javis-Data\ollama-models')
$resolvedData = Get-ConfiguredPath `
-ParameterValue $DataDir `
-EnvironmentName 'JAVIS_DATA_DIR' `
-DefaultValue (Join-Path $driveRoot 'Javis-Data\runtime')
$resolvedCache = Get-ConfiguredPath `
-ParameterValue $UvCacheDir `
-EnvironmentName 'JAVIS_UV_CACHE_DIR' `
-DefaultValue (Join-Path $driveRoot 'Javis-Tools\uv-cache')
$resolvedPython = Get-ConfiguredPath `
-ParameterValue $PythonInstallDir `
-EnvironmentName 'JAVIS_PYTHON_INSTALL_DIR' `
-DefaultValue (Join-Path $driveRoot 'Javis-Tools\python')
if (-not (Test-Path -LiteralPath $resolvedUv -PathType Leaf)) {
throw "uv.exe wurde nicht gefunden: $resolvedUv"
}
if (-not (Test-Path -LiteralPath $resolvedModels -PathType Container)) {
throw "Der externe Ollama-Modellpfad wurde nicht gefunden: $resolvedModels"
}
$env:JAVIS_DATA_DIR = $resolvedData
$env:OLLAMA_MODELS = $resolvedModels
$env:OLLAMA_HOST = '127.0.0.1:11434'
$env:UV_CACHE_DIR = $resolvedCache
$env:UV_PYTHON_INSTALL_DIR = $resolvedPython
if (-not (Test-OllamaReady)) {
if (-not (Test-Path -LiteralPath $resolvedOllama -PathType Leaf)) {
throw "Ollama ist nicht erreichbar und ollama.exe wurde nicht gefunden: $resolvedOllama"
}
Write-Host "Ollama wird lokal auf 127.0.0.1:11434 gestartet ..."
$ollamaProcess = Start-Process `
-FilePath $resolvedOllama `
-ArgumentList 'serve' `
-WindowStyle Hidden `
-PassThru
$ready = $false
for ($attempt = 1; $attempt -le 30; $attempt++) {
if ($ollamaProcess.HasExited) {
throw "Ollama wurde beendet, bevor die lokale API bereit war."
}
Start-Sleep -Milliseconds 500
if (Test-OllamaReady) {
$ready = $true
break
}
}
if (-not $ready) {
throw "Ollama war nach 15 Sekunden nicht unter 127.0.0.1:11434 erreichbar."
}
}
Push-Location $repositoryRoot
try {
& $resolvedUv run javis chat
exit $LASTEXITCODE
}
finally {
Pop-Location
}
+113
View File
@@ -0,0 +1,113 @@
"""Non-secret local TOML configuration."""
from __future__ import annotations
import os
import tomllib
from collections.abc import Mapping
from pathlib import Path
class LocalConfigError(ValueError):
"""A local configuration file is malformed or unsafe to replace."""
def _mapping(value: object, section: str) -> Mapping[str, object]:
if value is None:
return {}
if not isinstance(value, dict):
raise LocalConfigError(f"TOML-Abschnitt [{section}] muss eine Tabelle sein.")
return value
def _value(values: Mapping[str, object], name: str) -> str | None:
value = values.get(name)
if value is None:
return None
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (str, int, float)):
return str(value)
raise LocalConfigError(f"TOML-Wert '{name}' hat einen nicht unterstützten Typ.")
def load_local_defaults(path: Path) -> dict[str, str]:
"""Translate the supported non-secret TOML fields to settings inputs."""
if not path.exists():
return {}
try:
with path.open("rb") as handle:
document = tomllib.load(handle)
except (OSError, tomllib.TOMLDecodeError) as exc:
raise LocalConfigError(f"Lokale Konfiguration ist ungültig: {path}") from exc
providers = _mapping(document.get("providers"), "providers")
gemini = _mapping(providers.get("gemini"), "providers.gemini")
ollama = _mapping(providers.get("ollama"), "providers.ollama")
translated: dict[str, str] = {}
fields = (
(providers, "active", "JAVIS_PROVIDER_MODE"),
(ollama, "model", "JAVIS_MODEL"),
(ollama, "base_url", "JAVIS_OLLAMA_URL"),
(ollama, "timeout_seconds", "JAVIS_MODEL_TIMEOUT"),
(gemini, "enabled", "JAVIS_GEMINI_ENABLED"),
(gemini, "model", "JAVIS_GEMINI_MODEL"),
(gemini, "optional_model", "JAVIS_GEMINI_OPTIONAL_MODEL"),
(gemini, "free_only", "JAVIS_FREE_ONLY"),
(
gemini,
"billing_confirmed_disabled",
"JAVIS_GEMINI_BILLING_CONFIRMED_DISABLED",
),
(gemini, "max_requests_per_day", "JAVIS_MAX_CLOUD_REQUESTS_PER_DAY"),
(gemini, "max_input_chars", "JAVIS_MAX_CLOUD_INPUT_CHARS"),
(gemini, "max_output_tokens", "JAVIS_MAX_CLOUD_OUTPUT_TOKENS"),
(gemini, "timeout_seconds", "JAVIS_GEMINI_TIMEOUT"),
(gemini, "max_attempts", "JAVIS_GEMINI_MAX_ATTEMPTS"),
(gemini, "max_context_messages", "JAVIS_MAX_CLOUD_CONTEXT_MESSAGES"),
)
for table, toml_name, setting_name in fields:
value = _value(table, toml_name)
if value is not None:
translated[setting_name] = value
return translated
def write_gemini_activation(path: Path) -> None:
"""Create a conservative non-secret config without replacing existing data."""
if path.exists():
raise LocalConfigError(
f"Lokale Konfiguration existiert bereits und wurde nicht überschrieben: {path}"
)
content = """# Javis local configuration. This file must never contain secrets.
[providers]
active = "auto"
[providers.ollama]
model = "qwen3:8b"
base_url = "http://127.0.0.1:11434"
timeout_seconds = 180
[providers.gemini]
enabled = true
model = "gemini-3.6-flash"
optional_model = "gemini-3.5-flash-lite"
free_only = true
billing_confirmed_disabled = true
max_requests_per_day = 25
max_input_chars = 12000
max_output_tokens = 1024
timeout_seconds = 30
max_attempts = 2
max_context_messages = 6
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_text(content, encoding="utf-8")
os.replace(temporary_path, path)
except OSError as exc:
raise LocalConfigError(
f"Lokale Konfiguration konnte nicht geschrieben werden: {path}"
) from exc
+117 -4
View File
@@ -8,11 +8,42 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from javis.config.local_config import LocalConfigError, load_local_defaults
class ConfigurationError(ValueError): class ConfigurationError(ValueError):
"""Raised when runtime configuration is unsafe or invalid.""" """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( def default_data_dir(
environ: Mapping[str, str] | None = None, environ: Mapping[str, str] | None = None,
*, *,
@@ -42,35 +73,101 @@ class Settings:
model: str = "qwen3:8b" model: str = "qwen3:8b"
ollama_base_url: str = "http://127.0.0.1:11434" ollama_base_url: str = "http://127.0.0.1:11434"
timeout_seconds: float = 180.0 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 @property
def database_path(self) -> Path: def database_path(self) -> Path:
return self.data_dir / "sessions.sqlite3" return self.data_dir / "sessions.sqlite3"
@property
def usage_database_path(self) -> Path:
return self.data_dir / "provider-usage.sqlite3"
@property
def config_path(self) -> Path:
return self.data_dir / "javis.toml"
@classmethod @classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> Settings: def from_env(cls, environ: Mapping[str, str] | None = None) -> Settings:
values = os.environ if environ is None else environ environment = os.environ if environ is None else environ
configured_dir = values.get("JAVIS_DATA_DIR") configured_dir = environment.get("JAVIS_DATA_DIR")
data_dir = Path(configured_dir).expanduser() if configured_dir else default_data_dir(values) data_dir = (
Path(configured_dir).expanduser() if configured_dir else default_data_dir(environment)
)
if not data_dir.is_absolute(): if not data_dir.is_absolute():
raise ConfigurationError("JAVIS_DATA_DIR muss ein absoluter Pfad sein.") raise ConfigurationError("JAVIS_DATA_DIR muss ein absoluter Pfad sein.")
data_dir = data_dir.resolve()
configured_file = environment.get("JAVIS_CONFIG_FILE")
config_path = (
Path(configured_file).expanduser() if configured_file else data_dir / "javis.toml"
)
if not config_path.is_absolute():
raise ConfigurationError("JAVIS_CONFIG_FILE muss ein absoluter Pfad sein.")
try:
values = {**load_local_defaults(config_path.resolve()), **environment}
except LocalConfigError as exc:
raise ConfigurationError(str(exc)) from exc
provider = values.get("JAVIS_PROVIDER", "ollama").strip() provider = values.get("JAVIS_PROVIDER", "ollama").strip()
model = values.get("JAVIS_MODEL", "qwen3:8b").strip() model = values.get("JAVIS_MODEL", "qwen3:8b").strip()
base_url = values.get("JAVIS_OLLAMA_URL", "http://127.0.0.1:11434").strip() base_url = values.get("JAVIS_OLLAMA_URL", "http://127.0.0.1:11434").strip()
timeout_raw = values.get("JAVIS_MODEL_TIMEOUT", "180") 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: try:
timeout = float(timeout_raw) timeout = float(timeout_raw)
except ValueError as exc: except ValueError as exc:
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss eine Zahl sein.") from 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( settings = cls(
data_dir=data_dir.resolve(), data_dir=data_dir,
provider=provider, provider=provider,
model=model, model=model,
ollama_base_url=base_url, ollama_base_url=base_url,
timeout_seconds=timeout, 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() settings.validate()
return settings return settings
@@ -84,6 +181,22 @@ class Settings:
raise ConfigurationError("JAVIS_MODEL darf nicht leer sein.") raise ConfigurationError("JAVIS_MODEL darf nicht leer sein.")
if self.timeout_seconds <= 0: if self.timeout_seconds <= 0:
raise ConfigurationError("JAVIS_MODEL_TIMEOUT muss größer als 0 sein.") 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) parsed = urlparse(self.ollama_base_url)
if parsed.scheme != "http" or parsed.hostname not in { if parsed.scheme != "http" or parsed.hostname not in {
+121 -3
View File
@@ -2,16 +2,55 @@
from __future__ import annotations from __future__ import annotations
import re
from collections.abc import Iterator
from dataclasses import dataclass from dataclasses import dataclass
from javis.memory.sqlite_store import ChatSession, SQLiteSessionStore from javis.memory.sqlite_store import ChatSession, SQLiteSessionStore
from javis.providers.base import ChatMessage, LocalModelProvider from javis.providers.base import (
ChatMessage,
InvalidProviderResponseError,
LocalModelProvider,
)
class SessionProviderMismatchError(RuntimeError): class SessionProviderMismatchError(RuntimeError):
"""The active provider cannot safely continue the stored session.""" """The active provider cannot safely continue the stored session."""
_SENSITIVE_TITLE_PATTERN = re.compile(
r"(?ix)"
r"\b(?:api[-_ ]?key|passwort|password|token|secret|iban|kreditkarte|"
r"private[-_ ]?key|ssh[-_ ]?key)\b|"
r"-----BEGIN [A-Z ]+PRIVATE KEY-----|"
r"\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b|"
r"\b[\w.+-]+@[\w.-]+\.[A-Z]{2,}\b"
)
_MEDICAL_TITLE_PATTERN = re.compile(
r"(?ix)\b(?:arzt|ärzt|blut(?:e|en|ung)?|diagnos|gesundheit|krank|"
r"medikament|notfall|schmerz|symptom|therap|verletz|wunde)\w*\b"
)
def derive_session_title(text: str, *, limit: int = 60) -> str:
"""Create a short local title without retaining obvious sensitive values."""
normalized = " ".join(text.split())
if _SENSITIVE_TITLE_PATTERN.search(normalized):
return "Sensible Anfrage"
if _MEDICAL_TITLE_PATTERN.search(normalized):
return "Gesundheitsfrage"
cleaned = re.sub(r"[`*_#~]+", "", normalized).strip(" -:;,.!?")
if not cleaned:
return "Neue Unterhaltung"
if len(cleaned) <= limit:
return cleaned
shortened = cleaned[: limit - 1].rsplit(" ", 1)[0].rstrip(" -:;,.!?")
if not shortened:
shortened = cleaned[: limit - 1].rstrip()
return f"{shortened}"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LoadedSession: class LoadedSession:
session: ChatSession session: ChatSession
@@ -33,9 +72,33 @@ class ChatService:
def list_sessions(self) -> list[ChatSession]: def list_sessions(self) -> list[ChatSession]:
return self.store.list_sessions() return self.store.list_sessions()
def search_sessions(self, text: str) -> list[ChatSession]:
normalized = text.strip()
if not normalized:
raise ValueError("Für die Suche fehlt ein Text.")
return self.store.search_sessions(normalized)
def resolve_session(self, reference: str) -> LoadedSession:
session = self.store.resolve_unique_prefix(reference)
return self.load_session(session.id)
def rename_session(self, session_id: str, title: str) -> ChatSession:
normalized = " ".join(title.split()).strip()
if not normalized:
raise ValueError("Der Sitzungstitel darf nicht leer sein.")
if len(normalized) > 60:
normalized = f"{normalized[:59].rstrip()}"
return self.store.rename_session(session_id, normalized)
def load_session(self, session_id: str) -> LoadedSession: def load_session(self, session_id: str) -> LoadedSession:
session = self.store.get_session(session_id) 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( raise SessionProviderMismatchError(
"Die Sitzung verwendet " "Die Sitzung verwendet "
f"{session.provider}/{session.model}, aktiv ist " f"{session.provider}/{session.model}, aktiv ist "
@@ -50,8 +113,63 @@ class ChatService:
loaded = self.load_session(session_id) loaded = self.load_session(session_id)
messages = [*loaded.messages, ChatMessage("user", normalized)] messages = [*loaded.messages, ChatMessage("user", normalized)]
response = self.provider.chat(messages) response = self.provider.chat(messages)
self.store.append_exchange(session_id, normalized, response) self.store.append_exchange(
session_id,
normalized,
response,
title_if_first=derive_session_title(normalized),
last_provider=self._last_provider_name(),
)
return response return response
def stream_send(self, session_id: str, text: str) -> Iterator[str]:
normalized = text.strip()
if not normalized:
raise ValueError("Eine leere Nachricht wird nicht gesendet.")
loaded = self.load_session(session_id)
messages = [*loaded.messages, ChatMessage("user", normalized)]
stream_method = getattr(self.provider, "stream_chat", None)
stream = (
stream_method(messages)
if callable(stream_method)
else iter((self.provider.chat(messages),))
)
chunks: list[str] = []
completed = False
try:
for chunk in stream:
if not isinstance(chunk, str):
raise InvalidProviderResponseError(
"Der Provider lieferte einen ungültigen Streaming-Abschnitt."
)
if not chunk:
continue
chunks.append(chunk)
yield chunk
completed = True
finally:
if not completed:
close_stream = getattr(stream, "close", None)
if callable(close_stream):
close_stream()
response = "".join(chunks).strip()
if not response:
raise InvalidProviderResponseError(
"Der Provider lieferte keine verwendbare Streaming-Antwort."
)
self.store.append_exchange(
session_id,
normalized,
response,
title_if_first=derive_session_title(normalized),
last_provider=self._last_provider_name(),
)
def clear_session(self, session_id: str) -> None: def clear_session(self, session_id: str) -> None:
self.store.clear_messages(session_id) self.store.clear_messages(session_id)
def _last_provider_name(self) -> str:
route = getattr(self.provider, "last_route", None)
provider_name = getattr(route, "provider", None)
return provider_name if isinstance(provider_name, str) else self.provider.name
+499
View File
@@ -0,0 +1,499 @@
"""Local privacy and zero-cost routing between Gemini and Ollama."""
from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
from javis.providers.base import (
ChatMessage,
InvalidApiKeyError,
InvalidProviderResponseError,
LocalModelProvider,
MissingApiKeyError,
ProviderError,
ProviderUsage,
ResponseAbortedError,
)
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
cloud_suppressed_by_local_mode: bool
technical_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,
cloud_suppressed_by_local_mode=True,
)
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(
provider=cloud_provider.name,
privacy_policy=decision.policy,
fallback=False,
cloud_suppressed_by_local_mode=False,
technical_fallback=False,
reason="Cloudaufruf erfolgreich",
)
return response
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
current_text = messages[-1].content if messages else ""
decision = self._privacy_router.classify(
current_text,
cloud_requested=self.mode == "gemini",
)
if self.mode == "local":
yield from self._stream_local(
messages,
decision,
"lokaler Modus",
fallback=False,
cloud_suppressed_by_local_mode=True,
)
return
if decision.policy is CloudPolicy.NEVER:
yield from self._stream_local(
messages,
decision,
decision.reason,
fallback=True,
)
return
approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision)
if not approved:
yield from self._stream_local(
messages,
decision,
"Cloudfreigabe abgelehnt",
fallback=True,
)
return
unavailable_reason = self._cloud_unavailable_reason()
if unavailable_reason:
yield from self._stream_local(
messages,
decision,
unavailable_reason,
fallback=True,
)
return
if len(current_text) > self._max_cloud_input_chars:
yield from self._stream_local(
messages,
decision,
"Nachricht überschreitet das lokale Cloud-Größenlimit",
fallback=True,
)
return
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:
yield from self._stream_local(
messages,
decision,
"kein freigegebener Cloudkontext",
fallback=True,
)
return
emitted = False
try:
cloud_provider = self._cloud_provider_factory()
for chunk in self._provider_chunks(cloud_provider, cloud_messages):
emitted = True
yield chunk
except InvalidApiKeyError as exc:
self._cloud_session_disabled = True
if emitted:
self._raise_interrupted_cloud(decision, exc)
yield from self._stream_cloud_failure(
messages,
decision,
exc,
disable_notice=True,
)
return
except (MissingApiKeyError, ProviderError) as exc:
if emitted:
self._raise_interrupted_cloud(decision, exc)
yield from self._stream_cloud_failure(messages, decision, exc)
return
if not emitted:
error = InvalidProviderResponseError(
"Gemini hat keine verwendbare Streaming-Antwort geliefert."
)
yield from self._stream_cloud_failure(messages, decision, error)
return
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(
provider=cloud_provider.name,
privacy_policy=decision.policy,
fallback=False,
cloud_suppressed_by_local_mode=False,
technical_fallback=False,
reason="Cloudstream erfolgreich",
)
@staticmethod
def _provider_chunks(
provider: LocalModelProvider,
messages: list[ChatMessage],
) -> Iterator[str]:
stream_method = getattr(provider, "stream_chat", None)
chunks = (
stream_method(messages) if callable(stream_method) else iter((provider.chat(messages),))
)
completed = False
try:
for chunk in chunks:
if not isinstance(chunk, str):
raise InvalidProviderResponseError(
"Der Provider lieferte einen ungültigen Streaming-Abschnitt."
)
if chunk:
yield chunk
completed = True
finally:
if not completed:
close = getattr(chunks, "close", None)
if callable(close):
close()
def _stream_cloud_failure(
self,
messages: list[ChatMessage],
decision: PrivacyDecision,
error: ProviderError,
*,
disable_notice: bool = False,
) -> Iterator[str]:
self._record_cloud_error(decision, error)
reason = "Gemini nicht verfügbar"
if disable_notice:
reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert"
yield from self._stream_local(
messages,
decision,
reason,
fallback=True,
technical_fallback=True,
)
def _raise_interrupted_cloud(
self,
decision: PrivacyDecision,
error: ProviderError,
) -> None:
self._record_cloud_error(decision, error)
self.last_route = RouteStatus(
provider="gemini",
privacy_policy=decision.policy,
fallback=False,
cloud_suppressed_by_local_mode=False,
technical_fallback=True,
reason="Cloudstream nach Teilausgabe abgebrochen",
)
raise ResponseAbortedError(
"Der Gemini-Stream wurde nach einer Teilausgabe abgebrochen; "
"es wurde kein lokaler Ersatz angehängt."
) from error
def _stream_local(
self,
messages: list[ChatMessage],
decision: PrivacyDecision,
reason: str,
*,
fallback: bool,
cloud_suppressed_by_local_mode: bool = False,
technical_fallback: bool = False,
) -> Iterator[str]:
if fallback:
self._notice_callback(f"{reason} lokale Antwort mit Ollama.")
emitted = False
try:
for chunk in self._provider_chunks(self.local_provider, messages):
emitted = True
yield chunk
except ProviderError as exc:
self._record(
ProviderEvent(
provider=self.local_provider.name,
model=self.local_provider.model,
success=False,
error_category=type(exc).__name__,
fallback=fallback,
privacy_policy=decision.policy.value,
)
)
raise
if not emitted:
raise InvalidProviderResponseError(
"Ollama hat keine verwendbare Streaming-Antwort geliefert."
)
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(
provider=self.local_provider.name,
privacy_policy=decision.policy,
fallback=fallback,
cloud_suppressed_by_local_mode=cloud_suppressed_by_local_mode,
technical_fallback=technical_fallback,
reason=reason,
)
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:
self._record_cloud_error(decision, error)
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,
technical_fallback=True,
)
def _record_cloud_error(
self,
decision: PrivacyDecision,
error: ProviderError,
) -> None:
self._record(
ProviderEvent(
provider="gemini",
model=self._cloud_model,
success=False,
error_category=type(error).__name__,
fallback=False,
privacy_policy=decision.policy.value,
)
)
def _local(
self,
messages: list[ChatMessage],
decision: PrivacyDecision,
reason: str,
*,
fallback: bool,
cloud_suppressed_by_local_mode: bool = False,
technical_fallback: bool = False,
) -> 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(
provider=self.local_provider.name,
privacy_policy=decision.policy,
fallback=fallback,
cloud_suppressed_by_local_mode=cloud_suppressed_by_local_mode,
technical_fallback=technical_fallback,
reason=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."
)
+345 -16
View File
@@ -3,28 +3,46 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import getpass
import re
import sys import sys
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from pathlib import Path
from javis.config.local_config import LocalConfigError, write_gemini_activation
from javis.config.settings import ConfigurationError, Settings from javis.config.settings import ConfigurationError, Settings
from javis.core.chat_service import ChatService, SessionProviderMismatchError from javis.core.chat_service import ChatService, SessionProviderMismatchError
from javis.core.provider_router import HybridProvider
from javis.memory.sqlite_store import ( from javis.memory.sqlite_store import (
AmbiguousSessionReferenceError,
ChatSession, ChatSession,
SessionNotFoundError, SessionNotFoundError,
SessionStoreError, SessionStoreError,
SQLiteSessionStore, SQLiteSessionStore,
) )
from javis.providers.base import ProviderError from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError
from javis.providers.base import MissingApiKeyError, ProviderError
from javis.providers.gemini import GeminiProvider
from javis.providers.ollama import OllamaProvider from javis.providers.ollama import OllamaProvider
from javis.security.privacy import PrivacyDecision, PrivacyRouter
from javis.security.secrets import SecretProvider, SecretStoreError
InputFunction = Callable[[str], str] InputFunction = Callable[[str], str]
OutputFunction = Callable[[str], None] OutputFunction = Callable[[str], None]
StreamOutputFunction = Callable[[str], None]
StatusFunction = Callable[[], list[str]]
HELP_TEXT = """Befehle: HELP_TEXT = """Befehle:
/new neue Sitzung beginnen /new neue Sitzung beginnen
/sessions gespeicherte Sitzungen auflisten /sessions gespeicherte Sitzungen auflisten
/load <ID> frühere Sitzung laden und fortsetzen /load <NR|ID> Sitzung per Listennummer oder eindeutigem ID-Anfang laden
/search <TEXT> Titel und eigene Nachrichten durchsuchen
/rename <TITEL> aktive Sitzung umbenennen
/clear Nachrichten der aktiven Sitzung leeren /clear Nachrichten der aktiven Sitzung leeren
/provider aktiven Providermodus anzeigen
/provider <MODUS> Modus auto, local oder gemini setzen
/privacy letzte lokale Datenschutzentscheidung anzeigen
/status bereinigten Providerstatus anzeigen
/help diese Hilfe anzeigen /help diese Hilfe anzeigen
/exit Chat ordentlich beenden""" /exit Chat ordentlich beenden"""
@@ -34,9 +52,82 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten") chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten")
chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden") chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden")
configure_parser = subparsers.add_parser(
"configure",
help="nicht geheime lokale Einstellungen einrichten",
)
configure_parser.add_argument("provider_name", choices=["gemini"])
secrets_parser = subparsers.add_parser("secrets", help="lokale Geheimwerte sicher verwalten")
secret_subparsers = secrets_parser.add_subparsers(dest="secret_action", required=True)
set_parser = secret_subparsers.add_parser("set", help="Geheimwert verdeckt setzen")
set_parser.add_argument("secret_name", choices=["gemini"])
secret_subparsers.add_parser("status", help="nur Verfügbarkeit anzeigen")
delete_parser = secret_subparsers.add_parser("delete", help="Geheimwert löschen")
delete_parser.add_argument("secret_name", choices=["gemini"])
return parser return parser
def _confirmed(answer: str) -> bool:
return answer.strip().lower() in {"j", "ja", "y", "yes"}
def run_gemini_configuration(
config_path: Path,
*,
input_fn: InputFunction = input,
output: OutputFunction = print,
) -> int:
if not _confirmed(input_fn("Gemini Free Tier lokal aktivieren? [j/N] ")):
output("Gemini bleibt deaktiviert.")
return 1
if not _confirmed(
input_fn("Bestätigst du, dass für dieses Google-Projekt kein Billing aktiviert ist? [j/N] ")
):
output("Gemini bleibt deaktiviert; keine Konfiguration wurde geschrieben.")
return 1
try:
write_gemini_activation(config_path)
except LocalConfigError as exc:
output(f"Konfigurationsfehler: {exc}")
return 2
output(f"Gemini-Free-Konfiguration gespeichert: {config_path}")
output("Nächster Schritt: javis secrets set gemini")
return 0
def run_secrets(
action: str,
*,
provider: SecretProvider | None = None,
password_fn: Callable[[str], str] = getpass.getpass,
output: OutputFunction = print,
) -> int:
secret_provider = provider or SecretProvider()
try:
if action == "status":
state = "verfügbar" if secret_provider.gemini_key_available() else "nicht verfügbar"
output(f"Gemini-Schlüssel: {state}")
return 0
if action == "set":
value = password_fn("Gemini API-Key: ")
secret_provider.set_gemini_key(value)
output("Gemini-Schlüssel wurde sicher gespeichert.")
return 0
if action == "delete":
deleted = secret_provider.delete_gemini_key()
output(
"Gemini-Schlüssel wurde gelöscht."
if deleted
else "Gemini-Schlüssel war nicht verfügbar."
)
return 0
except SecretStoreError as exc:
output(f"Fehler: {exc}")
return 2
output("Unbekannte Secret-Aktion.")
return 2
def _show_history(service: ChatService, session_id: str, output: OutputFunction) -> None: def _show_history(service: ChatService, session_id: str, output: OutputFunction) -> None:
loaded = service.load_session(session_id) loaded = service.load_session(session_id)
if not loaded.messages: if not loaded.messages:
@@ -51,19 +142,57 @@ def _show_sessions(sessions: list[ChatSession], output: OutputFunction) -> None:
if not sessions: if not sessions:
output("Keine gespeicherten Sitzungen vorhanden.") output("Keine gespeicherten Sitzungen vorhanden.")
return return
for session in sessions: for number, session in enumerate(sessions, start=1):
date = session.updated_at[:10]
output( output(
f"{session.id} | {session.updated_at} | " f"{number}. {session.id[:8]} | {session.title} | {date} | "
f"{session.provider}/{session.model} | {session.message_count} Nachrichten" f"{session.message_count} Nachrichten | {session.last_provider}"
) )
class TerminalMarkdownRenderer:
"""Turn common Markdown markers into readable incremental terminal text."""
_tail_size = 3
def __init__(self) -> None:
self._pending = ""
@staticmethod
def _clean(text: str) -> str:
text = re.sub(r"(?m)^[ \t]{0,3}#{1,6}[ \t]+", "", text)
for marker in ("```", "**", "__", "~~", "`"):
text = text.replace(marker, "")
return text
def feed(self, chunk: str) -> str:
combined = self._pending + chunk
if len(combined) <= self._tail_size:
self._pending = combined
return ""
visible = combined[: -self._tail_size]
self._pending = combined[-self._tail_size :]
return self._clean(visible)
def finish(self) -> str:
visible = self._clean(self._pending)
self._pending = ""
return visible
def _terminal_write(text: str) -> None:
sys.stdout.write(text)
sys.stdout.flush()
def run_chat( def run_chat(
service: ChatService, service: ChatService,
*, *,
session_id: str | None = None, session_id: str | None = None,
input_fn: InputFunction = input, input_fn: InputFunction = input,
output: OutputFunction = print, output: OutputFunction = print,
stream_output: StreamOutputFunction | None = None,
status_fn: StatusFunction | None = None,
) -> int: ) -> int:
try: try:
if session_id: if session_id:
@@ -82,6 +211,7 @@ def run_chat(
return 2 return 2
output("Lokaler Javis-Chat. /help zeigt die Befehle.") output("Lokaler Javis-Chat. /help zeigt die Befehle.")
shown_sessions: list[ChatSession] = []
while True: while True:
try: try:
@@ -107,10 +237,33 @@ def run_chat(
continue continue
if entered == "/sessions": if entered == "/sessions":
try: try:
_show_sessions(service.list_sessions(), output) shown_sessions = service.list_sessions()
_show_sessions(shown_sessions, output)
except SessionStoreError as exc: except SessionStoreError as exc:
output(f"Fehler: {exc}") output(f"Fehler: {exc}")
continue continue
if entered.startswith("/search"):
parts = entered.split(maxsplit=1)
if len(parts) != 2 or not parts[1].strip():
output("Verwendung: /search <Text>")
continue
try:
shown_sessions = service.search_sessions(parts[1])
_show_sessions(shown_sessions, output)
except (SessionStoreError, ValueError) as exc:
output(f"Fehler: {exc}")
continue
if entered.startswith("/rename"):
parts = entered.split(maxsplit=1)
if len(parts) != 2 or not parts[1].strip():
output("Verwendung: /rename <neuer Titel>")
continue
try:
renamed = service.rename_session(active_id, parts[1])
output(f"Sitzung umbenannt: {renamed.title}")
except (SessionNotFoundError, SessionStoreError, ValueError) as exc:
output(f"Fehler: {exc}")
continue
if entered == "/clear": if entered == "/clear":
try: try:
service.clear_session(active_id) service.clear_session(active_id)
@@ -118,18 +271,66 @@ def run_chat(
except (SessionNotFoundError, SessionStoreError) as exc: except (SessionNotFoundError, SessionStoreError) as exc:
output(f"Fehler: {exc}") output(f"Fehler: {exc}")
continue continue
if entered.startswith("/provider"):
parts = entered.split()
provider = service.provider
if len(parts) == 1:
output(f"Aktiver Providermodus: {getattr(provider, 'mode', 'local')}")
continue
if len(parts) != 2 or parts[1] not in {"auto", "local", "gemini"}:
output("Verwendung: /provider auto|local|gemini")
continue
set_mode = getattr(provider, "set_mode", None)
if not callable(set_mode):
output("Dieser Chat unterstützt keinen wechselbaren Providermodus.")
continue
set_mode(parts[1])
output(f"Providermodus: {parts[1]}")
continue
if entered == "/privacy":
route = getattr(service.provider, "last_route", None)
if route is None:
output("Noch keine Datenschutzentscheidung in dieser Sitzung.")
else:
output(
"Datenschutzentscheidung: "
f"{route.privacy_policy.value}; Provider: {route.provider}; "
"Cloud durch Modus local unterdrückt: "
f"{'ja' if route.cloud_suppressed_by_local_mode else 'nein'}; "
f"Technischer Fallback: {'ja' if route.technical_fallback else 'nein'}"
)
continue
if entered == "/status":
if status_fn is None:
output("Keine erweiterte Statusprüfung verfügbar.")
else:
for line in status_fn():
output(line)
continue
if entered.startswith("/load"): if entered.startswith("/load"):
parts = entered.split(maxsplit=1) parts = entered.split(maxsplit=1)
if len(parts) != 2 or not parts[1].strip(): if len(parts) != 2 or not parts[1].strip():
output("Verwendung: /load <Sitzungs-ID>") output("Verwendung: /load <Sitzungs-ID>")
continue continue
try: try:
loaded = service.load_session(parts[1].strip()) reference = parts[1].strip()
if reference.isdecimal():
number = int(reference)
if not shown_sessions:
output("Zuerst /sessions oder /search anzeigen.")
continue
if number < 1 or number > len(shown_sessions):
output("Diese Listennummer ist nicht vorhanden.")
continue
loaded = service.load_session(shown_sessions[number - 1].id)
else:
loaded = service.resolve_session(reference)
active_id = loaded.session.id active_id = loaded.session.id
output(f"Sitzung geladen: {active_id}") output(f"Sitzung geladen: {active_id}")
_show_history(service, active_id, output) _show_history(service, active_id, output)
except ( except (
SessionNotFoundError, SessionNotFoundError,
AmbiguousSessionReferenceError,
SessionProviderMismatchError, SessionProviderMismatchError,
SessionStoreError, SessionStoreError,
) as exc: ) as exc:
@@ -139,15 +340,126 @@ def run_chat(
output("Unbekannter Befehl. /help zeigt die verfügbaren Befehle.") output("Unbekannter Befehl. /help zeigt die verfügbaren Befehle.")
continue continue
stream = service.stream_send(active_id, entered)
writer = stream_output or (_terminal_write if output is print else output)
renderer = TerminalMarkdownRenderer()
started = False
try: try:
response = service.send(active_id, entered) for chunk in stream:
output(f"Javis: {response}") visible = renderer.feed(chunk)
if not visible:
continue
if not started:
writer("Javis: ")
started = True
writer(visible)
remaining = renderer.finish()
if remaining:
if not started:
writer("Javis: ")
started = True
writer(remaining)
if started:
writer("\n")
except KeyboardInterrupt:
close = getattr(stream, "close", None)
if callable(close):
close()
if started:
writer("\n")
output("Generierung abgebrochen. Die unvollständige Antwort wurde nicht gespeichert.")
except (ProviderError, SessionStoreError, SessionProviderMismatchError) as exc: except (ProviderError, SessionStoreError, SessionProviderMismatchError) as exc:
if started:
writer("\n")
output(f"Fehler: {exc}") output(f"Fehler: {exc}")
except ValueError as exc: except ValueError as exc:
if started:
writer("\n")
output(f"Fehler: {exc}") output(f"Fehler: {exc}")
def build_runtime(
settings: Settings,
*,
input_fn: InputFunction,
output: OutputFunction,
secret_provider: SecretProvider | None = None,
) -> tuple[ChatService, StatusFunction]:
store = SQLiteSessionStore(settings.database_path)
usage_store = SQLiteUsageStore(settings.usage_database_path)
secrets = secret_provider or SecretProvider()
local = OllamaProvider(
settings.model,
settings.ollama_base_url,
settings.timeout_seconds,
)
def approve(decision: PrivacyDecision) -> bool:
answer = input_fn(
f"Cloudfreigabe erforderlich ({decision.reason}). Einmalig erlauben? [j/N] "
)
return _confirmed(answer)
def create_cloud_provider() -> GeminiProvider:
try:
key = secrets.get_gemini_key()
except SecretStoreError as exc:
raise MissingApiKeyError(
"Der Gemini-Schlüssel konnte nicht sicher geladen werden."
) from exc
return GeminiProvider(
model=settings.gemini_model,
api_key=key,
timeout_seconds=settings.gemini_timeout_seconds,
max_output_tokens=settings.max_cloud_output_tokens,
max_retries=settings.gemini_max_retries,
)
hybrid = HybridProvider(
local_provider=local,
cloud_provider_factory=create_cloud_provider,
cloud_model=settings.gemini_model,
privacy_router=PrivacyRouter(),
usage_store=usage_store,
approval_callback=approve,
notice_callback=output,
mode=settings.provider_mode,
cloud_enabled=settings.gemini_enabled,
billing_confirmed_disabled=settings.billing_confirmed_disabled,
free_only=settings.free_only,
max_cloud_requests_per_day=settings.max_cloud_requests_per_day,
max_cloud_input_chars=settings.max_cloud_input_chars,
max_cloud_context_messages=settings.max_cloud_context_messages,
)
def status() -> list[str]:
ollama = local.probe()
try:
key_available = secrets.gemini_key_available()
except SecretStoreError:
key_available = False
gemini_configured = (
settings.gemini_enabled and settings.free_only and settings.billing_confirmed_disabled
)
try:
cloud_requests = usage_store.cloud_requests_on()
local_fallbacks = usage_store.local_fallbacks_on()
except UsageStoreError:
cloud_requests = local_fallbacks = -1
return [
f"Ollama erreichbar: {'ja' if ollama.reachable else 'nein'}",
f"Lokales Modell vorhanden: {'ja' if ollama.model_available else 'nein'}",
f"Gemini-Schlüssel vorhanden: {'ja' if key_available else 'nein'}",
f"Gemini konfiguriert: {'ja' if gemini_configured else 'nein'}",
f"Nullkostenmodus: {'aktiv' if settings.free_only else 'inaktiv'}",
f"Aktiver Providermodus: {hybrid.mode}",
f"Heutige Cloudanfragen: {cloud_requests if cloud_requests >= 0 else 'unbekannt'}",
f"Heutige lokale Fallbacks: {local_fallbacks if local_fallbacks >= 0 else 'unbekannt'}",
]
return ChatService(store, hybrid), status
def main( def main(
argv: Sequence[str] | None = None, argv: Sequence[str] | None = None,
*, *,
@@ -155,26 +467,43 @@ def main(
output: OutputFunction = print, output: OutputFunction = print,
) -> int: ) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
if args.command == "secrets":
return run_secrets(
args.secret_action,
password_fn=input_fn if input_fn is not input else getpass.getpass,
output=output,
)
if args.command == "configure":
try:
settings = Settings.from_env()
except ConfigurationError as exc:
output(f"Konfigurationsfehler: {exc}")
return 2
return run_gemini_configuration(
settings.config_path,
input_fn=input_fn,
output=output,
)
if args.command != "chat": if args.command != "chat":
return 2 return 2
try: try:
settings = Settings.from_env() settings = Settings.from_env()
store = SQLiteSessionStore(settings.database_path) service, status_fn = build_runtime(
provider = OllamaProvider( settings,
settings.model, input_fn=input_fn,
settings.ollama_base_url, output=output,
settings.timeout_seconds,
) )
except (ConfigurationError, SessionStoreError) as exc: except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
output(f"Konfigurationsfehler: {exc}") output(f"Konfigurationsfehler: {exc}")
return 2 return 2
return run_chat( return run_chat(
ChatService(store, provider), service,
session_id=args.session, session_id=args.session,
input_fn=input_fn, input_fn=input_fn,
output=output, output=output,
status_fn=status_fn,
) )
+164 -13
View File
@@ -21,6 +21,10 @@ class SessionNotFoundError(SessionStoreError):
"""A requested session ID does not exist.""" """A requested session ID does not exist."""
class AmbiguousSessionReferenceError(SessionStoreError):
"""A short session reference matches multiple sessions."""
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ChatSession: class ChatSession:
id: str id: str
@@ -28,9 +32,14 @@ class ChatSession:
updated_at: str updated_at: str
provider: str provider: str
model: str model: str
title: str
last_provider: str
message_count: int = 0 message_count: int = 0
DEFAULT_SESSION_TITLE = "Neue Sitzung"
def _utc_now() -> str: def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds") return datetime.now(UTC).isoformat(timespec="seconds")
@@ -63,16 +72,21 @@ class SQLiteSessionStore:
def _initialize(self) -> None: def _initialize(self) -> None:
with self._connection() as connection: with self._connection() as connection:
connection.executescript( connection.execute(
""" """
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
provider TEXT NOT NULL, provider TEXT NOT NULL,
model TEXT NOT NULL model TEXT NOT NULL,
); title TEXT NOT NULL DEFAULT 'Neue Sitzung',
last_provider TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
@@ -82,10 +96,31 @@ class SQLiteSessionStore:
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE (session_id, position) UNIQUE (session_id, position)
); )
"""
)
columns = {
row["name"] for row in connection.execute("PRAGMA table_info(sessions)").fetchall()
}
if "title" not in columns:
connection.execute(
"ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT 'Neue Sitzung'"
)
if "last_provider" not in columns:
connection.execute(
"ALTER TABLE sessions ADD COLUMN last_provider TEXT NOT NULL DEFAULT ''"
)
connection.execute(
"""
UPDATE sessions
SET last_provider = provider
WHERE last_provider = ''
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_messages_session_position CREATE INDEX IF NOT EXISTS idx_messages_session_position
ON messages(session_id, position); ON messages(session_id, position)
""" """
) )
@@ -96,14 +131,31 @@ class SQLiteSessionStore:
with self._connection() as connection: with self._connection() as connection:
connection.execute( connection.execute(
""" """
INSERT INTO sessions (id, created_at, updated_at, provider, model) INSERT INTO sessions
VALUES (?, ?, ?, ?, ?) (id, created_at, updated_at, provider, model, title, last_provider)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(session_id, now, now, provider, model), (
session_id,
now,
now,
provider,
model,
DEFAULT_SESSION_TITLE,
provider,
),
) )
except sqlite3.Error as exc: except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from exc raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from exc
return ChatSession(session_id, now, now, provider, model) return ChatSession(
session_id,
now,
now,
provider,
model,
DEFAULT_SESSION_TITLE,
provider,
)
def get_session(self, session_id: str) -> ChatSession: def get_session(self, session_id: str) -> ChatSession:
try: try:
@@ -140,6 +192,84 @@ class SQLiteSessionStore:
raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc
return [self._session_from_row(row) for row in rows] return [self._session_from_row(row) for row in rows]
def search_sessions(self, text: str, *, limit: int = 20) -> list[ChatSession]:
normalized = text.strip()
if not normalized:
return []
escaped = normalized.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
try:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT s.*, COUNT(m.id) AS message_count
FROM sessions AS s
LEFT JOIN messages AS m ON m.session_id = s.id
WHERE s.title LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1
FROM messages AS searched
WHERE searched.session_id = s.id
AND searched.role = 'user'
AND searched.content LIKE ? ESCAPE '\\' COLLATE NOCASE
)
GROUP BY s.id
ORDER BY s.updated_at DESC, s.created_at DESC
LIMIT ?
""",
(pattern, pattern, max(1, min(limit, 100))),
).fetchall()
except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzungssuche ist fehlgeschlagen.") from exc
return [self._session_from_row(row) for row in rows]
def resolve_unique_prefix(self, prefix: str) -> ChatSession:
normalized = prefix.strip()
if not normalized:
raise SessionNotFoundError("Leerer Sitzungsverweis.")
escaped = normalized.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
try:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT id
FROM sessions
WHERE id LIKE ? ESCAPE '\\'
ORDER BY id
LIMIT 2
""",
(f"{escaped}%",),
).fetchall()
except sqlite3.Error as exc:
raise SessionStoreError("Der Sitzungsverweis konnte nicht geprüft werden.") from exc
if not rows:
raise SessionNotFoundError(f"Keine Sitzung beginnt mit '{normalized}'.")
if len(rows) > 1:
raise AmbiguousSessionReferenceError(
f"Der Sitzungsanfang '{normalized}' ist nicht eindeutig."
)
return self.get_session(rows[0]["id"])
def rename_session(self, session_id: str, title: str) -> ChatSession:
now = _utc_now()
try:
with self._connection() as connection:
cursor = connection.execute(
"""
UPDATE sessions
SET title = ?, updated_at = ?
WHERE id = ?
""",
(title, now, session_id),
)
if cursor.rowcount == 0:
raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.")
except SessionNotFoundError:
raise
except sqlite3.Error as exc:
raise SessionStoreError("Die Sitzung konnte nicht umbenannt werden.") from exc
return self.get_session(session_id)
def get_messages(self, session_id: str) -> list[ChatMessage]: def get_messages(self, session_id: str) -> list[ChatMessage]:
self.get_session(session_id) self.get_session(session_id)
try: try:
@@ -162,6 +292,9 @@ class SQLiteSessionStore:
session_id: str, session_id: str,
user_content: str, user_content: str,
assistant_content: str, assistant_content: str,
*,
title_if_first: str | None = None,
last_provider: str | None = None,
) -> None: ) -> None:
now = _utc_now() now = _utc_now()
try: try:
@@ -194,8 +327,24 @@ class SQLiteSessionStore:
), ),
) )
connection.execute( connection.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", """
(now, session_id), UPDATE sessions
SET updated_at = ?,
title = CASE
WHEN ? = 0 AND title = ? THEN COALESCE(?, title)
ELSE title
END,
last_provider = COALESCE(?, last_provider)
WHERE id = ?
""",
(
now,
next_position,
DEFAULT_SESSION_TITLE,
title_if_first,
last_provider,
session_id,
),
) )
except SessionNotFoundError: except SessionNotFoundError:
raise raise
@@ -232,5 +381,7 @@ class SQLiteSessionStore:
updated_at=row["updated_at"], updated_at=row["updated_at"],
provider=row["provider"], provider=row["provider"],
model=row["model"], model=row["model"],
title=row["title"],
last_provider=row["last_provider"],
message_count=row["message_count"], message_count=row["message_count"],
) )
+114
View File
@@ -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])
+41 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@@ -12,12 +13,18 @@ class ChatMessage:
content: str content: str
@dataclass(frozen=True, slots=True)
class ProviderUsage:
input_tokens: int | None = None
output_tokens: int | None = None
class ProviderError(RuntimeError): class ProviderError(RuntimeError):
"""Base class for expected local provider failures.""" """Base class for expected provider failures."""
class ProviderUnavailableError(ProviderError): class ProviderUnavailableError(ProviderError):
"""The local model runtime cannot be reached.""" """The selected model runtime cannot be reached."""
class ModelNotInstalledError(ProviderError): class ModelNotInstalledError(ProviderError):
@@ -25,7 +32,7 @@ class ModelNotInstalledError(ProviderError):
class ProviderTimeoutError(ProviderError): class ProviderTimeoutError(ProviderError):
"""The local model did not respond in time.""" """The model did not respond in time."""
class ResponseAbortedError(ProviderError): class ResponseAbortedError(ProviderError):
@@ -33,7 +40,27 @@ class ResponseAbortedError(ProviderError):
class InvalidProviderResponseError(ProviderError): class InvalidProviderResponseError(ProviderError):
"""The local runtime returned an unusable response.""" """The runtime returned an unusable response."""
class MissingApiKeyError(ProviderError):
"""No API key is available for the selected cloud provider."""
class InvalidApiKeyError(ProviderError):
"""The cloud provider rejected its API key."""
class ProviderRateLimitError(ProviderError):
"""The free provider quota or rate limit is exhausted."""
class CloudNetworkError(ProviderError):
"""A cloud request failed at the network layer."""
class CloudModelUnavailableError(ProviderError):
"""The configured cloud model is unavailable."""
@runtime_checkable @runtime_checkable
@@ -44,3 +71,13 @@ class LocalModelProvider(Protocol):
def chat(self, messages: list[ChatMessage]) -> str: def chat(self, messages: list[ChatMessage]) -> str:
"""Return one complete assistant response.""" """Return one complete assistant response."""
... ...
@runtime_checkable
class StreamingModelProvider(Protocol):
name: str
model: str
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
"""Yield only visible assistant text chunks."""
...
+196
View File
@@ -0,0 +1,196 @@
"""Gemini provider using Google's official GA Python SDK."""
from __future__ import annotations
from collections.abc import Callable, Iterator
from typing import Any
import httpx
from google import genai
from google.genai import errors, types
from javis.providers.base import (
ChatMessage,
CloudModelUnavailableError,
CloudNetworkError,
InvalidApiKeyError,
InvalidProviderResponseError,
MissingApiKeyError,
ProviderError,
ProviderRateLimitError,
ProviderTimeoutError,
ProviderUnavailableError,
ProviderUsage,
)
ClientFactory = Callable[..., Any]
class GeminiProvider:
name = "gemini"
def __init__(
self,
*,
model: str,
api_key: str | None,
timeout_seconds: float,
max_output_tokens: int,
max_retries: int,
client_factory: ClientFactory = genai.Client,
) -> None:
if not api_key:
raise MissingApiKeyError("Für Gemini ist kein API-Schlüssel hinterlegt.")
if max_retries not in {0, 1}:
raise ValueError("Gemini darf höchstens einen Wiederholungsversuch verwenden.")
self.model = model
self.last_usage = ProviderUsage()
self._config = types.GenerateContentConfig(
candidate_count=1,
max_output_tokens=max_output_tokens,
thinking_config=types.ThinkingConfig(include_thoughts=False),
)
self._client = client_factory(
api_key=api_key,
http_options=types.HttpOptions(
api_version="v1",
timeout=max(1, round(timeout_seconds * 1_000)),
retry_options=types.HttpRetryOptions(
attempts=max_retries + 1,
http_status_codes=[408, 500, 502, 503, 504],
),
),
)
def chat(self, messages: list[ChatMessage]) -> str:
contents = [
types.Content(
role="model" if message.role == "assistant" else "user",
parts=[types.Part.from_text(text=message.content)],
)
for message in messages
]
self.last_usage = ProviderUsage()
try:
response = self._client.models.generate_content(
model=self.model,
contents=contents,
config=self._config,
)
except errors.APIError as exc:
self._raise_api_error(exc)
except httpx.TimeoutException as exc:
raise ProviderTimeoutError("Gemini hat nicht rechtzeitig geantwortet.") from exc
except httpx.NetworkError as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
except (OSError, ConnectionError) as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
text = getattr(response, "text", None)
if not isinstance(text, str) or not text.strip():
raise InvalidProviderResponseError(
"Gemini hat keine verwendbare Textantwort geliefert."
)
usage = getattr(response, "usage_metadata", None)
self.last_usage = ProviderUsage(
input_tokens=self._token_count(usage, "prompt_token_count"),
output_tokens=self._token_count(usage, "candidates_token_count"),
)
return text.strip()
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
contents = [
types.Content(
role="model" if message.role == "assistant" else "user",
parts=[types.Part.from_text(text=message.content)],
)
for message in messages
]
self.last_usage = ProviderUsage()
visible_text_received = False
try:
responses = self._client.models.generate_content_stream(
model=self.model,
contents=contents,
config=self._config,
)
for response in responses:
usage = getattr(response, "usage_metadata", None)
if usage is not None:
self.last_usage = ProviderUsage(
input_tokens=self._token_count(usage, "prompt_token_count"),
output_tokens=self._token_count(
usage,
"candidates_token_count",
),
)
for text in self._visible_text_parts(response):
visible_text_received = True
yield text
except errors.APIError as exc:
self._raise_api_error(exc)
except httpx.TimeoutException as exc:
raise ProviderTimeoutError("Gemini hat nicht rechtzeitig geantwortet.") from exc
except httpx.NetworkError as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
except (OSError, ConnectionError) as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
if not visible_text_received:
raise InvalidProviderResponseError(
"Gemini hat keine verwendbare Streaming-Antwort geliefert."
)
@staticmethod
def _visible_text_parts(response: object) -> Iterator[str]:
candidates = getattr(response, "candidates", None)
if isinstance(candidates, list) and candidates:
for candidate in candidates:
content = getattr(candidate, "content", None)
parts = getattr(content, "parts", None)
if not isinstance(parts, list):
continue
for part in parts:
if getattr(part, "thought", False):
continue
text = getattr(part, "text", None)
if isinstance(text, str) and text:
yield text
return
text = getattr(response, "text", None)
if isinstance(text, str) and text:
yield text
@staticmethod
def _token_count(usage: object, name: str) -> int | None:
value = getattr(usage, name, None)
return value if isinstance(value, int) and value >= 0 else None
@staticmethod
def _raise_api_error(exc: errors.APIError) -> None:
if exc.code in {401, 403}:
raise InvalidApiKeyError("Der Gemini-API-Schlüssel wurde abgelehnt.") from exc
if exc.code == 429:
raise ProviderRateLimitError(
"Das kostenlose Gemini-Kontingent ist vorübergehend ausgeschöpft."
) from exc
if exc.code == 404:
raise CloudModelUnavailableError(
"Das konfigurierte Gemini-Modell ist nicht verfügbar."
) from exc
if exc.code == 408:
raise ProviderTimeoutError("Gemini hat nicht rechtzeitig geantwortet.") from exc
if exc.code >= 500:
raise ProviderUnavailableError("Gemini ist vorübergehend nicht verfügbar.") from exc
raise ProviderError(f"Gemini-Anfrage fehlgeschlagen (HTTP {exc.code}).") from exc
+102 -1
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json import json
import socket import socket
from collections.abc import Iterator
from dataclasses import dataclass
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -17,14 +19,40 @@ from javis.providers.base import (
) )
@dataclass(frozen=True, slots=True)
class OllamaStatus:
reachable: bool
model_available: bool
class OllamaProvider: class OllamaProvider:
name = "ollama" name = "ollama"
def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None: def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None:
self.model = model self.model = model
self._endpoint = f"{base_url.rstrip('/')}/api/chat" self._base_url = base_url.rstrip("/")
self._endpoint = f"{self._base_url}/api/chat"
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
def probe(self) -> OllamaStatus:
request = Request(f"{self._base_url}/api/tags", method="GET")
try:
with urlopen(request, timeout=min(self._timeout_seconds, 3)) as response:
result = json.loads(response.read())
except (HTTPError, URLError, TimeoutError, OSError, json.JSONDecodeError):
return OllamaStatus(False, False)
models = result.get("models")
if not isinstance(models, list):
return OllamaStatus(True, False)
names = {
value
for item in models
if isinstance(item, dict)
for value in (item.get("name"), item.get("model"))
if isinstance(value, str)
}
return OllamaStatus(True, self.model in names)
def chat(self, messages: list[ChatMessage]) -> str: def chat(self, messages: list[ChatMessage]) -> str:
payload = { payload = {
"model": self.model, "model": self.model,
@@ -80,3 +108,76 @@ class OllamaProvider:
"Ollama hat keine verwendbare Textantwort geliefert." "Ollama hat keine verwendbare Textantwort geliefert."
) )
return content.strip() return content.strip()
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
payload = {
"model": self.model,
"messages": [
{"role": message.role, "content": message.content} for message in messages
],
"stream": True,
"think": False,
}
request = Request(
self._endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
completed = False
visible_text_received = False
try:
with urlopen(request, timeout=self._timeout_seconds) as response:
for raw_line in response:
if not raw_line.strip():
continue
try:
result = json.loads(raw_line)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise InvalidProviderResponseError(
"Ollama hat einen ungültigen Streaming-Abschnitt geliefert."
) from exc
error = result.get("error")
if isinstance(error, str) and error:
if "not found" in error.lower():
raise ModelNotInstalledError(
f"Das lokale Modell '{self.model}' ist nicht installiert."
)
raise ProviderUnavailableError("Ollama hat den Stream abgelehnt.")
content = result.get("message", {}).get("content")
if content is not None and not isinstance(content, str):
raise InvalidProviderResponseError(
"Ollama hat ungültigen sichtbaren Text geliefert."
)
if content:
visible_text_received = True
yield content
if result.get("done") is True:
completed = True
break
except HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace")
if exc.code == 404 or "not found" in details.lower():
raise ModelNotInstalledError(
f"Das lokale Modell '{self.model}' ist nicht installiert."
) from exc
raise ProviderUnavailableError(f"Ollama meldet HTTP-Fehler {exc.code}.") from exc
except TimeoutError as exc:
raise ProviderTimeoutError(
"Die Modellantwort hat das Zeitlimit überschritten."
) from exc
except URLError as exc:
if isinstance(exc.reason, (TimeoutError, socket.timeout)):
raise ProviderTimeoutError(
"Die Modellantwort hat das Zeitlimit überschritten."
) from exc
raise ProviderUnavailableError(
"Ollama ist unter der konfigurierten lokalen Adresse nicht erreichbar."
) from exc
if not completed:
raise ResponseAbortedError("Der Ollama-Stream wurde vorzeitig beendet.")
if not visible_text_received:
raise InvalidProviderResponseError(
"Ollama hat keine verwendbare Streaming-Antwort geliefert."
)
+159
View File
@@ -0,0 +1,159 @@
"""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(symptom(?:e|en)?|blut(?:e|est|et|en|ung|ungen|wert|werte)?|"
r"verletz(?:ung|ungen|t)|wunde[n]?|medikament(?:e|en)?|diagnos(?:e|en|tiziert)|"
r"behandl(?:ung|ungen|e|en)|medizinisch(?:e[rsn]?|er)?\s+notfall|"
r"notaufnahme|rettungsdienst|gesundheit|krankheit(?:en)?|patient(?:en)?|"
r"arzt|ärzt(?:in|lich(?:e[rsn]?|er)?)|arztbericht|krankenhaus|"
r"psychotherapie|schmerz(?:en)?|fieber|atemnot|bewusstlos|ohnmacht|"
r"vergiftung|allergisch(?:e[rsn]?|er)?\s+reaktion)\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"(?<!\w)(?:\+?\d[\d ()/-]{7,}\d)(?!\w)"),
"mögliche Telefonnummer",
),
(
re.compile(
r"(?i)\b(intern(?:e|er|es|en)?\s+"
r"(?:hostname|server|domain|infrastruktur)|rootserver|"
r"privates? projekt|nicht öffentlich|systeminformation|"
r"private[rs]? termin)\b"
),
"nicht öffentliche Infrastruktur oder Planung",
),
(
re.compile(r"(?i)\b(ich|mein(?:e[rsn]?|em)?|mir|mich)\b"),
"möglicher persönlicher Bezug",
),
)
_ALLOWED_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(
r"(?i)\b(python|programmier|quellcode|algorithmus|sqlite|linux|windows|"
r"git|ram|arbeitsspeicher|ssd|solid state drive|http|statuscode|"
r"transaktion|öffentliche dokumentation|allgemeine wissensfrage|"
r"was ist|wie funktioniert)\b"
),
)
class PrivacyRouter:
"""Rule-based, entirely local and intentionally conservative."""
def classify(self, text: str, *, cloud_requested: bool = False) -> 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
+74
View File
@@ -0,0 +1,74 @@
"""Secret storage backed by the operating system credential store."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
import keyring
from keyring.errors import KeyringError, PasswordDeleteError
SERVICE_NAME = "javis-ai.local"
GEMINI_ACCOUNT = "gemini-api-key"
class SecretStoreError(RuntimeError):
"""A secret operation failed without exposing the secret value."""
@runtime_checkable
class SecretBackend(Protocol):
def get_password(self, service: str, account: str) -> str | None: ...
def set_password(self, service: str, account: str, value: str) -> None: ...
def delete_password(self, service: str, account: str) -> None: ...
class KeyringBackend:
"""Adapter around Python keyring and the active OS credential backend."""
def get_password(self, service: str, account: str) -> str | None:
return keyring.get_password(service, account)
def set_password(self, service: str, account: str, value: str) -> None:
keyring.set_password(service, account, value)
def delete_password(self, service: str, account: str) -> None:
keyring.delete_password(service, account)
class SecretProvider:
"""Named Javis secrets without any value-bearing status output."""
def __init__(self, backend: SecretBackend | None = None) -> None:
self._backend = backend or KeyringBackend()
def get_gemini_key(self) -> str | None:
try:
return self._backend.get_password(SERVICE_NAME, GEMINI_ACCOUNT)
except KeyringError as exc:
raise SecretStoreError(
"Der Betriebssystem-Schlüsselspeicher ist nicht verfügbar."
) from exc
def gemini_key_available(self) -> bool:
return bool(self.get_gemini_key())
def set_gemini_key(self, value: str) -> None:
if not value or not value.strip():
raise SecretStoreError("Ein leerer Gemini-Schlüssel wird nicht gespeichert.")
try:
self._backend.set_password(SERVICE_NAME, GEMINI_ACCOUNT, value.strip())
except KeyringError as exc:
raise SecretStoreError(
"Der Gemini-Schlüssel konnte nicht sicher gespeichert werden."
) from exc
def delete_gemini_key(self) -> bool:
try:
self._backend.delete_password(SERVICE_NAME, GEMINI_ACCOUNT)
except PasswordDeleteError:
return False
except KeyringError as exc:
raise SecretStoreError("Der Gemini-Schlüssel konnte nicht gelöscht werden.") from exc
return True
+76 -2
View File
@@ -2,9 +2,9 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from javis.core.chat_service import ChatService from javis.core.chat_service import ChatService, derive_session_title
from javis.memory.sqlite_store import SQLiteSessionStore from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage, LocalModelProvider from javis.providers.base import ChatMessage, LocalModelProvider, ProviderUnavailableError
class RecordingProvider: class RecordingProvider:
@@ -19,6 +19,19 @@ class RecordingProvider:
return f"Antwort {len(self.calls)}" return f"Antwort {len(self.calls)}"
class StreamingProvider(RecordingProvider):
def __init__(self, *, fail_after_first: bool = False) -> None:
super().__init__()
self.fail_after_first = fail_after_first
def stream_chat(self, messages: list[ChatMessage]):
self.calls.append(messages)
yield "Teil "
if self.fail_after_first:
raise ProviderUnavailableError("Streamfehler")
yield "Antwort"
class ChatServiceTests(unittest.TestCase): class ChatServiceTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory() self.temporary_directory = tempfile.TemporaryDirectory()
@@ -51,6 +64,67 @@ class ChatServiceTests(unittest.TestCase):
"Antwort 2", "Antwort 2",
], ],
) )
self.assertEqual(loaded.session.title, "Hallo")
def test_title_is_local_short_and_redacts_obvious_sensitive_content(self) -> None:
self.assertEqual(
derive_session_title("Erkläre SQLite-Transaktionen für Anfänger"),
"Erkläre SQLite-Transaktionen für Anfänger",
)
self.assertLessEqual(derive_session_title("Wort " * 30).__len__(), 60)
self.assertEqual(
derive_session_title("Mein API-Key ist ABC123 und funktioniert nicht"),
"Sensible Anfrage",
)
self.assertEqual(
derive_session_title("Wenn ich blute, sollte ich zum Arzt?"),
"Gesundheitsfrage",
)
def test_manual_title_is_normalized_and_limited(self) -> None:
session = self.service.new_session()
renamed = self.service.rename_session(session.id, " Mein Titel ")
shortened = self.service.rename_session(session.id, "x" * 80)
self.assertEqual(renamed.title, "Mein Titel")
self.assertEqual(len(shortened.title), 60)
self.assertTrue(shortened.title.endswith(""))
def test_streaming_response_is_persisted_exactly_once_after_completion(self) -> None:
provider = StreamingProvider()
service = ChatService(self.service.store, provider)
session = service.new_session()
chunks = list(service.stream_send(session.id, "Hallo"))
self.assertEqual(chunks, ["Teil ", "Antwort"])
self.assertEqual(
[message.content for message in service.load_session(session.id).messages],
["Hallo", "Teil Antwort"],
)
self.assertEqual(service.load_session(session.id).session.message_count, 2)
def test_closed_stream_does_not_persist_partial_response(self) -> None:
provider = StreamingProvider()
service = ChatService(self.service.store, provider)
session = service.new_session()
stream = service.stream_send(session.id, "Hallo")
self.assertEqual(next(stream), "Teil ")
stream.close()
self.assertEqual(service.load_session(session.id).messages, [])
def test_provider_error_during_stream_does_not_persist_partial_response(self) -> None:
provider = StreamingProvider(fail_after_first=True)
service = ChatService(self.service.store, provider)
session = service.new_session()
with self.assertRaises(ProviderUnavailableError):
list(service.stream_send(session.id, "Hallo"))
self.assertEqual(service.load_session(session.id).messages, [])
if __name__ == "__main__": if __name__ == "__main__":
+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()
+255 -2
View File
@@ -1,11 +1,16 @@
import sqlite3
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from javis.config.settings import Settings
from javis.core.chat_service import ChatService from javis.core.chat_service import ChatService
from javis.interface.cli import run_chat from javis.core.provider_router import HybridProvider
from javis.interface.cli import build_runtime, run_chat, run_gemini_configuration
from javis.memory.sqlite_store import SQLiteSessionStore from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy
class _FakeProvider: class _FakeProvider:
@@ -15,6 +20,46 @@ class _FakeProvider:
def chat(self, messages: list[ChatMessage]) -> str: def chat(self, messages: list[ChatMessage]) -> str:
return f"Echo: {messages[-1].content}" return f"Echo: {messages[-1].content}"
def stream_chat(self, messages: list[ChatMessage]):
yield "Echo: "
yield messages[-1].content
class _MarkdownProvider(_FakeProvider):
def stream_chat(self, messages: list[ChatMessage]):
yield "## **Ant"
yield "wort**\n`SQLite`"
class _InterruptingProvider(_FakeProvider):
def stream_chat(self, messages: list[ChatMessage]):
yield "angefangene Antwort"
raise KeyboardInterrupt
class _FakeHybridProvider(_FakeProvider):
name = "hybrid"
model = "auto"
def __init__(self) -> None:
self.mode = "auto"
self.last_route = None
def set_mode(self, mode: str) -> None:
self.mode = mode
class _LazySecrets:
def __init__(self) -> None:
self.reads = 0
def get_gemini_key(self) -> str | None:
self.reads += 1
return None
def gemini_key_available(self) -> bool:
return False
class CliTests(unittest.TestCase): class CliTests(unittest.TestCase):
def test_basic_chat_commands(self) -> None: def test_basic_chat_commands(self) -> None:
@@ -31,10 +76,218 @@ class CliTests(unittest.TestCase):
) )
self.assertEqual(result, 0) self.assertEqual(result, 0)
self.assertTrue(any(line == "Javis: Echo: Hallo" for line in output)) self.assertIn("Javis: Echo: Hallo\n", "".join(output))
self.assertTrue(any("Nachrichten" in line for line in output)) self.assertTrue(any("Nachrichten" in line for line in output))
self.assertEqual(output[-1], "Chat beendet.") self.assertEqual(output[-1], "Chat beendet.")
def test_streaming_cleans_terminal_markdown_but_persists_raw_answer(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
service = ChatService(store, _MarkdownProvider())
inputs = iter(["Frage", "/exit"])
output: list[str] = []
streamed: list[str] = []
result = run_chat(
service,
input_fn=lambda _prompt: next(inputs),
output=output.append,
stream_output=streamed.append,
)
session = service.list_sessions()[0]
messages = service.load_session(session.id).messages
self.assertEqual(result, 0)
self.assertEqual("".join(streamed), "Javis: Antwort\nSQLite\n")
self.assertEqual(messages[-1].content, "## **Antwort**\n`SQLite`")
def test_keyboard_interrupt_drops_partial_answer_and_returns_to_prompt(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
service = ChatService(store, _InterruptingProvider())
inputs = iter(["Frage", "/exit"])
output: list[str] = []
streamed: list[str] = []
result = run_chat(
service,
input_fn=lambda _prompt: next(inputs),
output=output.append,
stream_output=streamed.append,
)
session = service.list_sessions()[0]
messages = service.load_session(session.id).messages
self.assertEqual(result, 0)
self.assertEqual(messages, [])
self.assertTrue(any("nicht gespeichert" in line for line in output))
self.assertEqual(output[-1], "Chat beendet.")
def test_provider_privacy_and_status_commands_are_sanitized(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
provider = _FakeHybridProvider()
provider.last_route = SimpleNamespace(
privacy_policy=CloudPolicy.ASK,
provider="ollama",
cloud_suppressed_by_local_mode=True,
technical_fallback=False,
)
service = ChatService(store, provider)
inputs = iter(
[
"/provider",
"/provider local",
"/privacy",
"/status",
"/exit",
]
)
output: list[str] = []
result = run_chat(
service,
input_fn=lambda _prompt: next(inputs),
output=output.append,
status_fn=lambda: [
"Gemini-Schlüssel vorhanden: ja",
"Heutige Cloudanfragen: 0",
],
)
self.assertEqual(result, 0)
self.assertIn("Aktiver Providermodus: auto", output)
self.assertIn("Providermodus: local", output)
self.assertTrue(
any(
"Datenschutzentscheidung: ask; Provider: ollama; "
"Cloud durch Modus local unterdrückt: ja; "
"Technischer Fallback: nein" in line
for line in output
)
)
self.assertIn("Gemini-Schlüssel vorhanden: ja", output)
def test_numbered_sessions_load_rename_and_search(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
service = ChatService(store, _FakeProvider())
first = service.new_session()
service.send(first.id, "Erste Frage")
second = service.new_session()
service.send(second.id, "Zweite Frage")
shown_before = service.list_sessions()
target = shown_before[1]
inputs = iter(
[
"/sessions",
"/load 2",
"/rename Gefundene Sitzung",
"/search Gefundene",
"/exit",
]
)
output: list[str] = []
result = run_chat(
service,
session_id=first.id,
input_fn=lambda _prompt: next(inputs),
output=output.append,
)
renamed_title = store.get_session(target.id).title
self.assertEqual(result, 0)
self.assertTrue(any(line.startswith("1. ") and "Frage" in line for line in output))
self.assertIn(f"Sitzung geladen: {target.id}", output)
self.assertEqual(renamed_title, "Gefundene Sitzung")
self.assertTrue(any("Gefundene Sitzung" in line for line in output))
def test_load_accepts_unique_prefix_and_rejects_ambiguous_prefix(self) -> None:
with tempfile.TemporaryDirectory() as directory:
database = Path(directory) / "sessions.sqlite3"
store = SQLiteSessionStore(database)
service = ChatService(store, _FakeProvider())
active = service.new_session()
with sqlite3.connect(database) as connection:
connection.executemany(
"""
INSERT INTO sessions
(id, created_at, updated_at, provider, model, title, last_provider)
VALUES (?, '2026-01-01', '2026-01-01',
'ollama', 'test-model', ?, 'ollama')
""",
(
("shared-111", "Eins"),
("shared-222", "Zwei"),
("unique-333", "Drei"),
),
)
connection.close()
inputs = iter(["/load unique", "/load shared", "/exit"])
output: list[str] = []
result = run_chat(
service,
session_id=active.id,
input_fn=lambda _prompt: next(inputs),
output=output.append,
)
self.assertEqual(result, 0)
self.assertIn("Sitzung geladen: unique-333", output)
self.assertTrue(any("nicht eindeutig" in line for line in output))
def test_gemini_configuration_defaults_to_no(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "javis.toml"
output: list[str] = []
result = run_gemini_configuration(
path,
input_fn=lambda _prompt: "",
output=output.append,
)
self.assertEqual(result, 1)
self.assertFalse(path.exists())
self.assertIn("deaktiviert", output[-1])
def test_gemini_configuration_requires_both_confirmations(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "javis.toml"
answers = iter(["ja", "ja"])
result = run_gemini_configuration(
path,
input_fn=lambda _prompt: next(answers),
output=lambda _line: None,
)
self.assertEqual(result, 0)
self.assertTrue(path.exists())
content = path.read_text(encoding="utf-8")
self.assertIn("billing_confirmed_disabled = true", content)
self.assertNotIn("api_key", content.lower())
def test_runtime_wires_hybrid_provider_without_loading_key_at_start(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = Settings(data_dir=Path(directory))
secrets = _LazySecrets()
service, _status = build_runtime(
settings,
input_fn=lambda _prompt: "",
output=lambda _line: None,
secret_provider=secrets,
)
session = service.new_session()
self.assertIsInstance(service.provider, HybridProvider)
self.assertEqual(session.provider, "hybrid")
self.assertEqual(session.model, "auto")
self.assertEqual(secrets.reads, 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from typing import Any
import httpx
from google.genai import errors
from javis.providers.base import (
ChatMessage,
CloudNetworkError,
InvalidApiKeyError,
InvalidProviderResponseError,
MissingApiKeyError,
ProviderRateLimitError,
ProviderTimeoutError,
)
from javis.providers.gemini import GeminiProvider
class _FakeModels:
def __init__(self, result: object) -> None:
self.result = result
self.call: dict[str, Any] | None = None
self.stream_call: dict[str, Any] | None = None
def generate_content(self, **kwargs: object) -> object:
self.call = kwargs
if isinstance(self.result, BaseException):
raise self.result
return self.result
def generate_content_stream(self, **kwargs: object):
self.stream_call = kwargs
if isinstance(self.result, BaseException):
raise self.result
if isinstance(self.result, list):
return iter(self.result)
return iter((self.result,))
class _FakeClient:
def __init__(self, result: object) -> None:
self.models = _FakeModels(result)
class _ClientFactory:
def __init__(self, result: object) -> None:
self.client = _FakeClient(result)
self.arguments: dict[str, Any] | None = None
def __call__(self, **kwargs: object) -> _FakeClient:
self.arguments = kwargs
return self.client
def _response(text: str = "Cloud-Antwort") -> SimpleNamespace:
return SimpleNamespace(
text=text,
usage_metadata=SimpleNamespace(
prompt_token_count=12,
candidates_token_count=7,
),
)
def _stream_response(
visible_text: str,
*,
thought_text: str | None = None,
with_usage: bool = False,
) -> SimpleNamespace:
parts = []
if thought_text:
parts.append(SimpleNamespace(text=thought_text, thought=True))
parts.append(SimpleNamespace(text=visible_text, thought=False))
usage = SimpleNamespace(prompt_token_count=12, candidates_token_count=7) if with_usage else None
return SimpleNamespace(
candidates=[SimpleNamespace(content=SimpleNamespace(parts=parts))],
usage_metadata=usage,
)
class GeminiProviderTests(unittest.TestCase):
def _provider(
self, result: object, **overrides: object
) -> tuple[GeminiProvider, _ClientFactory]:
factory = _ClientFactory(result)
arguments: dict[str, object] = {
"model": "gemini-3.6-flash",
"api_key": "test-key-not-a-real-secret",
"timeout_seconds": 30,
"max_output_tokens": 1024,
"max_retries": 1,
"client_factory": factory,
}
arguments.update(overrides)
return GeminiProvider(**arguments), factory
def test_success_uses_bounded_sdk_configuration_and_tracks_usage(self) -> None:
provider, factory = self._provider(_response())
answer = provider.chat(
[
ChatMessage("user", "Hallo"),
ChatMessage("assistant", "Guten Tag"),
ChatMessage("user", "Was ist SQLite?"),
]
)
self.assertEqual(answer, "Cloud-Antwort")
self.assertEqual(provider.last_usage.input_tokens, 12)
self.assertEqual(provider.last_usage.output_tokens, 7)
self.assertNotIn("test-key-not-a-real-secret", repr(factory.client.models.call))
http_options = factory.arguments["http_options"]
self.assertEqual(http_options.api_version, "v1")
self.assertEqual(http_options.timeout, 30_000)
self.assertEqual(http_options.retry_options.attempts, 2)
self.assertNotIn(429, http_options.retry_options.http_status_codes)
call = factory.client.models.call
self.assertEqual(call["model"], "gemini-3.6-flash")
self.assertEqual([content.role for content in call["contents"]], ["user", "model", "user"])
self.assertEqual(call["config"].max_output_tokens, 1024)
self.assertIsNone(call["config"].tools)
self.assertFalse(call["config"].thinking_config.include_thoughts)
def test_streaming_yields_only_visible_text_and_tracks_usage(self) -> None:
provider, factory = self._provider(
[
_stream_response("Cloud ", thought_text="verstecktes Denken"),
_stream_response("Antwort", with_usage=True),
]
)
chunks = list(provider.stream_chat([ChatMessage("user", "Was ist SQLite?")]))
self.assertEqual(chunks, ["Cloud ", "Antwort"])
self.assertNotIn("verstecktes Denken", "".join(chunks))
self.assertEqual(provider.last_usage.input_tokens, 12)
self.assertEqual(provider.last_usage.output_tokens, 7)
self.assertEqual(
factory.client.models.stream_call["model"],
"gemini-3.6-flash",
)
def test_streaming_error_is_sanitized(self) -> None:
provider, _ = self._provider(
errors.ClientError(
429,
{"message": "quota test-key-not-a-real-secret"},
)
)
with self.assertRaises(ProviderRateLimitError) as raised:
list(provider.stream_chat([ChatMessage("user", "Was ist Python?")]))
self.assertNotIn("test-key-not-a-real-secret", str(raised.exception))
def test_missing_key_stops_before_client_creation(self) -> None:
factory = _ClientFactory(_response())
with self.assertRaises(MissingApiKeyError):
GeminiProvider(
model="gemini-3.6-flash",
api_key=None,
timeout_seconds=30,
max_output_tokens=1024,
max_retries=1,
client_factory=factory,
)
self.assertIsNone(factory.arguments)
def test_invalid_key_is_sanitized(self) -> None:
provider, _ = self._provider(
errors.ClientError(
401,
{"message": "rejected test-key-not-a-real-secret"},
)
)
with self.assertRaisesRegex(InvalidApiKeyError, "wurde abgelehnt") as raised:
provider.chat([ChatMessage("user", "Hallo")])
self.assertNotIn("test-key-not-a-real-secret", str(raised.exception))
def test_rate_limit_is_a_distinct_fallback_signal(self) -> None:
provider, _ = self._provider(errors.ClientError(429, {"message": "quota"}))
with self.assertRaises(ProviderRateLimitError):
provider.chat([ChatMessage("user", "Was ist Python?")])
def test_network_failure_is_a_distinct_fallback_signal(self) -> None:
request = httpx.Request("POST", "https://example.invalid")
provider, _ = self._provider(httpx.ConnectError("offline", request=request))
with self.assertRaises(CloudNetworkError):
provider.chat([ChatMessage("user", "Was ist Python?")])
def test_timeout_is_a_distinct_fallback_signal(self) -> None:
request = httpx.Request("POST", "https://example.invalid")
provider, _ = self._provider(httpx.ReadTimeout("timeout", request=request))
with self.assertRaises(ProviderTimeoutError):
provider.chat([ChatMessage("user", "Was ist Python?")])
def test_empty_response_is_rejected(self) -> None:
provider, _ = self._provider(_response(" "))
with self.assertRaises(InvalidProviderResponseError):
provider.chat([ChatMessage("user", "Was ist Python?")])
def test_more_than_one_retry_is_rejected(self) -> None:
with self.assertRaises(ValueError):
self._provider(_response(), max_retries=2)
if __name__ == "__main__":
unittest.main()
+76 -7
View File
@@ -4,22 +4,47 @@ import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import ClassVar from typing import ClassVar
from javis.providers.base import ChatMessage from javis.providers.base import ChatMessage, ResponseAbortedError
from javis.providers.ollama import OllamaProvider from javis.providers.ollama import OllamaProvider
class _OllamaHandler(BaseHTTPRequestHandler): class _OllamaHandler(BaseHTTPRequestHandler):
request_payload: ClassVar[dict[str, object]] = {} request_payload: ClassVar[dict[str, object]] = {}
abort_stream: ClassVar[bool] = False
def do_POST(self) -> None: def do_POST(self) -> None:
length = int(self.headers["Content-Length"]) length = int(self.headers["Content-Length"])
type(self).request_payload = json.loads(self.rfile.read(length)) type(self).request_payload = json.loads(self.rfile.read(length))
body = json.dumps( if type(self).request_payload["stream"]:
{ chunks = [
"message": {"role": "assistant", "content": "Lokale Antwort"}, {
"done": True, "message": {"role": "assistant", "content": "Lokale "},
} "done": False,
).encode() }
]
if not type(self).abort_stream:
chunks.append(
{
"message": {"role": "assistant", "content": "Antwort"},
"done": True,
}
)
body = b"".join(json.dumps(chunk).encode() + b"\n" for chunk in chunks)
else:
body = json.dumps(
{
"message": {"role": "assistant", "content": "Lokale Antwort"},
"done": True,
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
body = json.dumps({"models": [{"name": "test-model"}]}).encode()
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -31,6 +56,9 @@ class _OllamaHandler(BaseHTTPRequestHandler):
class OllamaProviderTests(unittest.TestCase): class OllamaProviderTests(unittest.TestCase):
def setUp(self) -> None:
_OllamaHandler.abort_stream = False
def test_provider_uses_local_chat_endpoint(self) -> None: def test_provider_uses_local_chat_endpoint(self) -> None:
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler) server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True) thread = threading.Thread(target=server.serve_forever, daemon=True)
@@ -42,6 +70,7 @@ class OllamaProviderTests(unittest.TestCase):
2, 2,
) )
response = provider.chat([ChatMessage("user", "Hallo")]) response = provider.chat([ChatMessage("user", "Hallo")])
status = provider.probe()
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
@@ -50,6 +79,46 @@ class OllamaProviderTests(unittest.TestCase):
self.assertEqual(response, "Lokale Antwort") self.assertEqual(response, "Lokale Antwort")
self.assertEqual(_OllamaHandler.request_payload["model"], "test-model") self.assertEqual(_OllamaHandler.request_payload["model"], "test-model")
self.assertFalse(_OllamaHandler.request_payload["stream"]) self.assertFalse(_OllamaHandler.request_payload["stream"])
self.assertTrue(status.reachable)
self.assertTrue(status.model_available)
def test_provider_streams_visible_text_without_thinking(self) -> None:
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
provider = OllamaProvider(
"test-model",
f"http://127.0.0.1:{server.server_port}",
2,
)
chunks = list(provider.stream_chat([ChatMessage("user", "Hallo")]))
finally:
server.shutdown()
server.server_close()
thread.join()
self.assertEqual(chunks, ["Lokale ", "Antwort"])
self.assertTrue(_OllamaHandler.request_payload["stream"])
self.assertFalse(_OllamaHandler.request_payload["think"])
def test_provider_rejects_incomplete_stream(self) -> None:
_OllamaHandler.abort_stream = True
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
provider = OllamaProvider(
"test-model",
f"http://127.0.0.1:{server.server_port}",
2,
)
with self.assertRaises(ResponseAbortedError):
list(provider.stream_chat([ChatMessage("user", "Hallo")]))
finally:
server.shutdown()
server.server_close()
thread.join()
if __name__ == "__main__": if __name__ == "__main__":
+116
View File
@@ -0,0 +1,116 @@
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:
for question in (
"Erkläre in zwei Sätzen den Unterschied zwischen RAM und SSD.",
"Wie funktioniert eine SQLite-Transaktion?",
"Was bedeutet HTTP 404?",
"Wie funktioniert Git?",
):
with self.subTest(question=question):
self.assertEqual(
self.router.classify(question).policy,
CloudPolicy.ALLOWED,
)
def test_internal_server_question_requires_approval(self) -> None:
decision = self.router.classify(
"Wie konfiguriere ich den internen Server mit Hostname srv-intern-01?"
)
self.assertEqual(decision.policy, CloudPolicy.ASK)
def test_technical_question_with_api_key_is_never(self) -> None:
decision = self.router.classify("Wie nutze ich diesen API-Key in Python: AIza" + "x" * 25)
self.assertEqual(decision.policy, CloudPolicy.NEVER)
def test_personal_technical_situation_requires_approval(self) -> None:
decision = self.router.classify(
"Meine SSD ist voll. Wie verschiebe ich meine persönlichen Dateien?"
)
self.assertEqual(decision.policy, CloudPolicy.ASK)
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_medical_questions_are_always_never(self) -> None:
for question in (
"Wenn ich blute, sollte ich dann zum Arzt?",
"Welche Symptome hat eine Grippe?",
"Wie nehme ich dieses Medikament ein?",
"Was bedeutet diese Diagnose?",
"Wie sollte eine Verletzung behandelt werden?",
):
with self.subTest(question=question):
self.assertEqual(
self.router.classify(question).policy,
CloudPolicy.NEVER,
)
def test_technical_terms_cannot_override_medical_never(self) -> None:
decision = self.router.classify(
"Wie speichere ich meine Symptome und Diagnose mit Python in SQLite?"
)
self.assertEqual(decision.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()
+362
View File
@@ -0,0 +1,362 @@
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,
CloudModelUnavailableError,
CloudNetworkError,
InvalidApiKeyError,
LocalModelProvider,
MissingApiKeyError,
ProviderRateLimitError,
ProviderTimeoutError,
ProviderUsage,
ResponseAbortedError,
)
from javis.security.privacy import CloudPolicy, PrivacyRouter
class _RecordingProvider:
def __init__(
self,
name: str,
model: str,
*,
answer: str = "Antwort",
error: Exception | None = None,
stream_chunks: tuple[str, ...] | None = None,
stream_error: Exception | None = None,
) -> None:
self.name = name
self.model = model
self.answer = answer
self.error = error
self.stream_chunks = stream_chunks
self.stream_error = stream_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
def stream_chat(self, messages: list[ChatMessage]):
self.calls.append(messages)
if self.stream_chunks is None:
if self.error:
raise self.error
yield self.answer
else:
yield from self.stream_chunks
if self.stream_error:
raise self.stream_error
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_medical_never_routes_locally_before_cloud_construction(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", "Wenn ich blute, sollte ich dann zum Arzt?")])
self.assertEqual(answer, "Lokal")
self.assertFalse(constructed)
self.assertFalse(self.approvals)
self.assertEqual(router.last_route.privacy_policy, CloudPolicy.NEVER)
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_timeout_and_missing_model_fall_back(self) -> None:
for error in (
ProviderTimeoutError("timeout"),
CloudModelUnavailableError("missing model"),
):
with self.subTest(error=type(error).__name__):
self.cloud.error = error
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)
self.assertFalse(router.last_route.fallback)
self.assertTrue(router.last_route.cloud_suppressed_by_local_mode)
self.assertFalse(router.last_route.technical_fallback)
def test_network_failure_is_reported_as_technical_fallback(self) -> None:
self.cloud.error = CloudNetworkError("offline")
router = self._router()
router.chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertFalse(router.last_route.cloud_suppressed_by_local_mode)
self.assertTrue(router.last_route.technical_fallback)
def test_allowed_cloud_response_streams_visible_chunks(self) -> None:
self.cloud.stream_chunks = ("Cloud ", "Stream")
router = self._router()
chunks = list(router.stream_chat([ChatMessage("user", "Wie funktioniert SQLite?")]))
self.assertEqual(chunks, ["Cloud ", "Stream"])
self.assertEqual(router.last_route.provider, "gemini")
self.assertFalse(router.last_route.fallback)
def test_cloud_stream_error_before_output_falls_back_locally(self) -> None:
self.cloud.stream_chunks = ()
self.cloud.stream_error = CloudNetworkError("offline")
self.local.stream_chunks = ("Lokaler ", "Ersatz")
router = self._router()
chunks = list(router.stream_chat([ChatMessage("user", "Wie funktioniert Python?")]))
self.assertEqual(chunks, ["Lokaler ", "Ersatz"])
self.assertTrue(router.last_route.fallback)
self.assertTrue(router.last_route.technical_fallback)
self.assertIn("lokale Antwort", self.notices[-1])
def test_cloud_stream_error_after_output_never_appends_local_answer(self) -> None:
self.cloud.stream_chunks = ("Teilantwort",)
self.cloud.stream_error = CloudNetworkError("offline")
self.local.stream_chunks = ("Lokaler Ersatz",)
router = self._router()
stream = router.stream_chat([ChatMessage("user", "Wie funktioniert Python?")])
self.assertEqual(next(stream), "Teilantwort")
with self.assertRaises(ResponseAbortedError):
next(stream)
self.assertFalse(self.local.calls)
self.assertFalse(router.last_route.fallback)
self.assertTrue(router.last_route.technical_fallback)
def test_medical_never_streams_locally_before_cloud_construction(self) -> None:
constructed = False
self.local.stream_chunks = ("Lokal",)
def cloud_factory() -> _RecordingProvider:
nonlocal constructed
constructed = True
return self.cloud
router = self._router(cloud_provider_factory=cloud_factory, mode="gemini")
chunks = list(
router.stream_chat([ChatMessage("user", "Welche Diagnose passt zu meinen Schmerzen?")])
)
self.assertEqual(chunks, ["Lokal"])
self.assertFalse(constructed)
self.assertFalse(self.approvals)
self.assertEqual(router.last_route.privacy_policy, CloudPolicy.NEVER)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
import unittest
from keyring.errors import PasswordDeleteError
from javis.interface.cli import run_secrets
from javis.security.secrets import GEMINI_ACCOUNT, SERVICE_NAME, SecretProvider
class _FakeBackend:
def __init__(self) -> None:
self.values: dict[tuple[str, str], str] = {}
def get_password(self, service: str, account: str) -> str | None:
return self.values.get((service, account))
def set_password(self, service: str, account: str, value: str) -> None:
self.values[(service, account)] = value
def delete_password(self, service: str, account: str) -> None:
try:
del self.values[(service, account)]
except KeyError as exc:
raise PasswordDeleteError("missing") from exc
class SecretProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.backend = _FakeBackend()
self.provider = SecretProvider(self.backend)
def test_key_is_initially_not_available(self) -> None:
self.assertFalse(self.provider.gemini_key_available())
def test_set_and_delete_key(self) -> None:
self.provider.set_gemini_key("test-secret-value")
self.assertTrue(self.provider.gemini_key_available())
self.assertEqual(
self.backend.values[(SERVICE_NAME, GEMINI_ACCOUNT)],
"test-secret-value",
)
self.assertTrue(self.provider.delete_gemini_key())
self.assertFalse(self.provider.gemini_key_available())
def test_cli_set_uses_hidden_input_and_never_outputs_value(self) -> None:
output: list[str] = []
secret = "test-secret-value"
result = run_secrets(
"set",
provider=self.provider,
password_fn=lambda _prompt: secret,
output=output.append,
)
self.assertEqual(result, 0)
self.assertTrue(self.provider.gemini_key_available())
self.assertNotIn(secret, "\n".join(output))
def test_cli_status_only_reports_availability(self) -> None:
output: list[str] = []
self.provider.set_gemini_key("test-secret-value")
result = run_secrets("status", provider=self.provider, output=output.append)
self.assertEqual(result, 0)
self.assertEqual(output, ["Gemini-Schlüssel: verfügbar"])
self.assertNotIn("test-secret-value", "\n".join(output))
def test_cli_delete_handles_missing_key(self) -> None:
output: list[str] = []
result = run_secrets("delete", provider=self.provider, output=output.append)
self.assertEqual(result, 0)
self.assertEqual(output, ["Gemini-Schlüssel war nicht verfügbar."])
if __name__ == "__main__":
unittest.main()
+83
View File
@@ -1,6 +1,12 @@
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from javis.config.local_config import (
LocalConfigError,
load_local_defaults,
write_gemini_activation,
)
from javis.config.settings import ConfigurationError, Settings, default_data_dir from javis.config.settings import ConfigurationError, Settings, default_data_dir
@@ -34,6 +40,83 @@ 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",
}
)
def test_local_toml_enables_only_confirmed_free_gemini(self) -> None:
with tempfile.TemporaryDirectory() as directory:
data_dir = Path(directory)
write_gemini_activation(data_dir / "javis.toml")
settings = Settings.from_env({"JAVIS_DATA_DIR": str(data_dir)})
self.assertTrue(settings.gemini_enabled)
self.assertTrue(settings.free_only)
self.assertTrue(settings.billing_confirmed_disabled)
self.assertEqual(settings.provider_mode, "auto")
self.assertEqual(settings.gemini_max_retries, 1)
def test_environment_can_safely_disable_local_cloud_config(self) -> None:
with tempfile.TemporaryDirectory() as directory:
data_dir = Path(directory)
write_gemini_activation(data_dir / "javis.toml")
settings = Settings.from_env(
{
"JAVIS_DATA_DIR": str(data_dir),
"JAVIS_GEMINI_ENABLED": "false",
}
)
self.assertFalse(settings.gemini_enabled)
def test_activation_file_contains_no_secret_and_is_not_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "javis.toml"
write_gemini_activation(path)
content = path.read_text(encoding="utf-8")
self.assertNotIn("api_key", content.lower())
self.assertNotIn("password", content.lower())
self.assertEqual(
load_local_defaults(path)["JAVIS_GEMINI_BILLING_CONFIRMED_DISABLED"],
"true",
)
with self.assertRaises(LocalConfigError):
write_gemini_activation(path)
def test_malformed_local_config_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
data_dir = Path(directory)
(data_dir / "javis.toml").write_text("[providers", encoding="utf-8")
with self.assertRaises(ConfigurationError):
Settings.from_env({"JAVIS_DATA_DIR": str(data_dir)})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+116 -3
View File
@@ -1,15 +1,20 @@
import sqlite3
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from javis.memory.sqlite_store import SessionNotFoundError, SQLiteSessionStore from javis.memory.sqlite_store import (
AmbiguousSessionReferenceError,
SessionNotFoundError,
SQLiteSessionStore,
)
class SQLiteSessionStoreTests(unittest.TestCase): class SQLiteSessionStoreTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory() self.temporary_directory = tempfile.TemporaryDirectory()
database = Path(self.temporary_directory.name) / "sessions.sqlite3" self.database = Path(self.temporary_directory.name) / "sessions.sqlite3"
self.store = SQLiteSessionStore(database) self.store = SQLiteSessionStore(self.database)
def tearDown(self) -> None: def tearDown(self) -> None:
self.temporary_directory.cleanup() self.temporary_directory.cleanup()
@@ -21,6 +26,8 @@ class SQLiteSessionStoreTests(unittest.TestCase):
self.assertEqual(loaded.id, created.id) self.assertEqual(loaded.id, created.id)
self.assertEqual(loaded.provider, "ollama") self.assertEqual(loaded.provider, "ollama")
self.assertEqual(loaded.model, "test-model") self.assertEqual(loaded.model, "test-model")
self.assertEqual(loaded.title, "Neue Sitzung")
self.assertEqual(loaded.last_provider, "ollama")
self.assertEqual(self.store.list_sessions()[0].id, created.id) self.assertEqual(self.store.list_sessions()[0].id, created.id)
def test_messages_keep_exchange_order(self) -> None: def test_messages_keep_exchange_order(self) -> None:
@@ -53,6 +60,112 @@ class SQLiteSessionStoreTests(unittest.TestCase):
self.assertEqual(self.store.get_messages(session.id), []) self.assertEqual(self.store.get_messages(session.id), [])
self.assertEqual(self.store.get_session(session.id).message_count, 0) self.assertEqual(self.store.get_session(session.id).message_count, 0)
def test_first_exchange_sets_title_and_last_provider_atomically(self) -> None:
session = self.store.create_session("hybrid", "auto")
self.store.append_exchange(
session.id,
"Frage",
"Antwort",
title_if_first="Kurzer Titel",
last_provider="gemini",
)
self.store.append_exchange(
session.id,
"Noch eine Frage",
"Noch eine Antwort",
title_if_first="Darf Titel nicht ersetzen",
last_provider="ollama",
)
loaded = self.store.get_session(session.id)
self.assertEqual(loaded.title, "Kurzer Titel")
self.assertEqual(loaded.last_provider, "ollama")
def test_old_database_is_migrated_idempotently_without_data_loss(self) -> None:
database = Path(self.temporary_directory.name) / "legacy.sqlite3"
with sqlite3.connect(database) as connection:
connection.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
position INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (session_id, position)
);
INSERT INTO sessions VALUES
('legacy', '2026-01-01', '2026-01-02', 'ollama', 'old-model');
INSERT INTO messages
(session_id, position, role, content, created_at)
VALUES ('legacy', 0, 'user', 'Alte Nachricht', '2026-01-02');
"""
)
connection.close()
migrated = SQLiteSessionStore(database)
migrated_again = SQLiteSessionStore(database)
session = migrated_again.get_session("legacy")
self.assertEqual(session.title, "Neue Sitzung")
self.assertEqual(session.last_provider, "ollama")
self.assertEqual(
[message.content for message in migrated.get_messages("legacy")],
["Alte Nachricht"],
)
def test_rename_and_search_only_titles_and_user_messages(self) -> None:
first = self.store.create_session("ollama", "test-model")
second = self.store.create_session("ollama", "test-model")
self.store.rename_session(first.id, "SQLite Hilfe")
self.store.append_exchange(first.id, "Transaktion erklären", "Privates Lösungswort")
self.store.append_exchange(second.id, "Andere Frage", "Nur SQLite in Antwort")
self.assertEqual(self.store.get_session(first.id).title, "SQLite Hilfe")
self.assertEqual(
[session.id for session in self.store.search_sessions("Transaktion")],
[first.id],
)
self.assertEqual(self.store.search_sessions("Lösungswort"), [])
self.assertEqual(self.store.search_sessions("SQLite"), [self.store.get_session(first.id)])
def test_search_treats_sql_wildcards_and_injection_as_plain_text(self) -> None:
session = self.store.create_session("ollama", "test-model")
self.store.rename_session(session.id, "100% SQLite_Name")
self.assertEqual(self.store.search_sessions("%")[0].id, session.id)
self.assertEqual(self.store.search_sessions("_")[0].id, session.id)
self.assertEqual(self.store.search_sessions("' OR 1=1 --"), [])
def test_unique_and_ambiguous_session_prefixes(self) -> None:
with sqlite3.connect(self.database) as connection:
connection.executemany(
"""
INSERT INTO sessions
(id, created_at, updated_at, provider, model, title, last_provider)
VALUES (?, '2026-01-01', '2026-01-01', 'ollama', 'model', ?, 'ollama')
""",
(
("abc111", "Eins"),
("abc222", "Zwei"),
("unique333", "Drei"),
),
)
connection.close()
self.assertEqual(self.store.resolve_unique_prefix("unique").id, "unique333")
with self.assertRaises(AmbiguousSessionReferenceError):
self.store.resolve_unique_prefix("abc")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+30
View File
@@ -0,0 +1,30 @@
import unittest
from pathlib import Path
class StartScriptTests(unittest.TestCase):
def test_script_is_local_overridable_and_avoids_system_changes(self) -> None:
repository_root = Path(__file__).resolve().parents[2]
script = (repository_root / "scripts" / "start-javis.ps1").read_text(encoding="utf-8")
normalized = script.lower()
self.assertIn("$psscriptroot", normalized)
self.assertIn("http://127.0.0.1:11434/api/version", normalized)
self.assertIn("-windowstyle hidden", normalized)
self.assertIn("javis_uv_path", normalized)
self.assertIn("javis_ollama_path", normalized)
self.assertIn("javis_data_dir", normalized)
self.assertIn("run javis chat", normalized)
self.assertNotIn("c:\\users\\", normalized)
for forbidden in (
"set-executionpolicy",
"setx ",
"new-service",
"schtasks",
"hkey_",
):
self.assertNotIn(forbidden, normalized)
if __name__ == "__main__":
unittest.main()
+46
View File
@@ -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()
Generated
+798
View File
@@ -1,11 +1,429 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.11" requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" },
{ url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" },
{ url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" },
{ url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" },
{ url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" },
{ url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" },
{ url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" },
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" },
{ url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" },
{ url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" },
{ url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" },
{ url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" },
{ url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" },
{ url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" },
{ url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" },
{ url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" },
{ url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" },
{ url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" },
{ url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" },
{ url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" },
{ url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" },
{ url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" },
{ url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" },
{ url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" },
{ url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" },
{ url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" },
{ url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
{ url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
{ url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
{ url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
{ url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
{ url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "google-auth"
version = "2.56.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
]
[[package]]
name = "google-genai"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/e6/ff83088427072cc9d5d21036788cf0ed08cc4906e4a5810e469553a43185/google_genai-2.16.0.tar.gz", hash = "sha256:c4c2524926001b18073db927a5d75bb7c8be7b5fd13ab507d599f51fff2284c5", size = 647939, upload-time = "2026-07-30T14:34:37.366Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/c6/f111056110030b1a5fb949687d7f93c2b4e8996f6494ae32efb049482796/google_genai-2.16.0-py3-none-any.whl", hash = "sha256:f9eda6a7a3dd4491a0d2253c4bdd4536462d63838ed3f1b0e4fb9a0eb8f43331", size = 1050096, upload-time = "2026-07-30T14:34:35.578Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" },
]
[[package]] [[package]]
name = "javis-ai" name = "javis-ai"
version = "0.0.0" version = "0.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [
{ name = "google-genai" },
{ name = "keyring" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
@@ -13,10 +431,221 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [
{ name = "google-genai", specifier = ">=2.6,<3" },
{ name = "keyring", specifier = ">=25.7,<26" },
]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.12,<1" }] dev = [{ name = "ruff", specifier = ">=0.12,<1" }]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "more-itertools"
version = "11.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.0" version = "0.16.0"
@@ -41,3 +670,172 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
] ]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "tenacity"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "websockets"
version = "16.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" },
{ url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" },
{ url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" },
{ url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" },
{ url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" },
{ url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" },
{ url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" },
{ url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" },
{ url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" },
{ url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" },
{ url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" },
{ url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" },
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" },
{ url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" },
{ url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" },
{ url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" },
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
]
[[package]]
name = "zipp"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
]