Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f4fc23e1d | |||
| cba80dfeb1 | |||
| 571969b69b | |||
| cf35148576 | |||
| 1658e0485e | |||
| d3e85f2077 | |||
| 3361eae564 | |||
| cbba1024d7 | |||
| 328dcd9f74 | |||
| 43c1f76d84 | |||
| 112ae6d1da | |||
| 00a5a6ce8c | |||
| 9148193806 | |||
| 77b510bd22 | |||
| b52757702d | |||
| 564a3fdbd3 | |||
| 7a14fe87f9 | |||
| a6c46f34e4 | |||
| a6e4576848 | |||
| a0af15a037 | |||
| 141ff2e013 | |||
| af9426edc4 | |||
| ab6ea485dc | |||
| e57186f678 | |||
| f3e11831fb | |||
| 286d96da33 | |||
| 570e7fa153 | |||
| a28e2d8bc9 | |||
| cc34f14ece | |||
| 8a678ae353 | |||
| 0a550e6d9f | |||
| f3d6dfb13b |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ 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.
|
||||||
|
- PySide6-Desktop-MVP mit Sitzungsleiste, Markdownchat, Qt-Streamingworker,
|
||||||
|
sicherem Abbruch, Datenschutzdialog und bereinigter Statusspalte.
|
||||||
|
- Sicherer Startweg `scripts/start-javis-gui.ps1`, der die vorhandene
|
||||||
|
Loopback-/Prozesslogik des CLI-Starters wiederverwendet.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -29,8 +43,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.
|
||||||
|
|||||||
@@ -4,34 +4,55 @@ 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
|
Gemini-Free-Hybridrouting, Datenschutz, Streaming und Sitzungsnavigation sind
|
||||||
Sitzungen sowie SQLite-Persistenz außerhalb des Repositories.
|
implementiert. Zusätzlich steht ein erster PySide6-Desktopclient zur manuellen
|
||||||
|
Abnahme bereit.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Desktopoberfläche:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\start-javis-gui.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ: `uv run javis gui`. Die [Desktop-Anleitung](docs/DESKTOP_UI.md)
|
||||||
|
beschreibt Tastenkürzel, Datenschutzdialog und den manuellen Abnahmetest.
|
||||||
|
|
||||||
|
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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
+141
-33
@@ -45,7 +45,7 @@ Rootserver:
|
|||||||
|
|
||||||
## Aktueller technischer Stand
|
## Aktueller technischer Stand
|
||||||
|
|
||||||
- Der erste lokale Textchat ist auf `feat/core-chat` implementiert und abgenommen.
|
- Der erste lokale Textchat ist auf `main` implementiert, abgenommen und nach Gitea gepusht.
|
||||||
- `uv run javis chat` liefert echte lokale Antworten und speichert Sitzungen in SQLite.
|
- `uv run javis chat` liefert echte lokale Antworten und speichert Sitzungen in SQLite.
|
||||||
- Konfigurationsbeispiel, Sicherheitsdokumente, ADRs und Strukturtest sind vorhanden.
|
- Konfigurationsbeispiel, Sicherheitsdokumente, ADRs und Strukturtest sind vorhanden.
|
||||||
- Living-Mind-Verzeichnis ist ausschließlich ein inaktiver Vertrag ohne Frontend oder Fantasiedaten.
|
- Living-Mind-Verzeichnis ist ausschließlich ein inaktiver Vertrag ohne Frontend oder Fantasiedaten.
|
||||||
@@ -58,11 +58,65 @@ 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.
|
||||||
|
- Desktop-MVP auf `feat/desktop-ui` begonnen: PySide6, gemeinsamer CLI-/GUI-
|
||||||
|
Runtime-Builder, Qt-freier Controller und ruhiges Drei-Spalten-Grundgerüst vorhanden.
|
||||||
|
- Sitzungen, Suche, Umbenennen, Markdownanzeige, Qt-Streamingworker, sicherer
|
||||||
|
Abbruch, Modus und bereinigte Statusanzeige sind im Desktopclient verdrahtet.
|
||||||
|
- `ask` unterstützt `Einmal erlauben`, `Lokal beantworten` und `Abbrechen`;
|
||||||
|
`never` erreicht weiterhin keinen Cloudfreigabedialog.
|
||||||
|
- Startwege `uv run javis gui` und `scripts/start-javis-gui.ps1` funktionieren;
|
||||||
|
der GUI-Starter verwendet die sichere Loopback-/Prozesslogik des CLI-Starters.
|
||||||
|
|
||||||
## Aktuelle Architektur
|
## Aktuelle Architektur
|
||||||
|
|
||||||
@@ -70,9 +124,15 @@ 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
|
||||||
- lokale Modelle zuerst; keine kostenpflichtige API und kein stiller Cloud-Fallback
|
- Hybridprovider bevorzugt Gemini für cloudgeeignete Inhalte und verwendet
|
||||||
|
Ollama bei Datenschutz, Ablehnung, Offline-, Quota- und Providerfehlern
|
||||||
|
- strikter Nullkostenmodus ohne Billing-Aktivierung oder kostenpflichtigen 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
|
||||||
- Modell erhält niemals Passwörter, Tokens oder private Schlüssel
|
- Modell erhält niemals Passwörter, Tokens oder private Schlüssel
|
||||||
- keine freie Shell; spätere Tools benötigen feste Schemas, Allowlists, Timeouts und Freigaben
|
- keine freie Shell; spätere Tools benötigen feste Schemas, Allowlists, Timeouts und Freigaben
|
||||||
@@ -101,6 +161,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
|
||||||
|
|
||||||
@@ -115,9 +197,22 @@ 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
|
- Tests: 101 bestanden; zusätzlich Qt-Offscreen-Fenster, Dialogzuordnung und
|
||||||
|
sichere Wiederverwendung des Startskripts abgedeckt
|
||||||
|
- `uv run javis gui` im Offscreen-Modus: erfolgreich, Exitcode 0
|
||||||
|
- echter lokaler GUI-Smoke: erfolgreich; 1 Sitzung, 2 Nachrichten, Provider Ollama
|
||||||
|
- GUI-Startskript mit prozesslokalem ExecutionPolicy-Bypass: erfolgreich
|
||||||
|
- manuelle Desktop-Abnahme durch Pascal: noch offen
|
||||||
|
- 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:
|
||||||
|
|
||||||
@@ -135,37 +230,46 @@ 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: `feat/core-chat`
|
- aktueller Arbeitsbranch: `feat/desktop-ui`
|
||||||
- stabiler Branch: `main`
|
- stabiler Ausgangsstand: `main` bei `d3e85f2`
|
||||||
- Grundgerüstcommit: `6d04171`
|
- GUI-Grundgerüst und Kernadapter: `1658e04`
|
||||||
- letzter Commit vor der Handoff-Verdichtung: `0e82748`
|
- Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148`
|
||||||
- Handoff-Verdichtung auf `main`: `e6eb995`
|
- Tests, Startskript und Desktopdokumentation: `571969b`
|
||||||
- Python-Entwicklungsumgebung auf dem Feature-Branch: `fa90a5c`
|
- `feat/desktop-ui` bis `571969b` gepusht und mit Remote synchron verifiziert
|
||||||
- Laufzeit-/Modelldokumentation: `10d7cd7`
|
- Chatkomfort-Merge nach `main`: `cbba102`
|
||||||
- Chatkern und CLI: `ec9b2f9`
|
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
|
||||||
- Unit-Tests: `b153daa`
|
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
|
||||||
|
- `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
|
||||||
- `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
|
||||||
|
Klartext-XML wird nicht für API-Schlüssel verwendet
|
||||||
|
- Google-Quoten bleiben projektabhängig und müssen in AI Studio geprüft werden;
|
||||||
|
lokale Limits sind nur zusätzliche Schutzgrenzen
|
||||||
- Gaming-PC zeigt derzeit nur 8 statt 16 logische CPU-Prozessoren; Ursache ungeklärt
|
- 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
|
||||||
|
|
||||||
@@ -174,6 +278,9 @@ CLI-/Persistenz-Smoke:
|
|||||||
- lokale Modellqualität und Geschwindigkeit unterscheiden sich stark zwischen RTX 3060 und GTX 1050
|
- lokale Modellqualität und Geschwindigkeit unterscheiden sich stark zwischen RTX 3060 und GTX 1050
|
||||||
- ATM10 und Javis dürfen den ASUS-Laptop nicht gleichzeitig überlasten
|
- ATM10 und Javis dürfen den ASUS-Laptop nicht gleichzeitig überlasten
|
||||||
- Secrets können über Logs, Prompts, Screenshots oder Git abfließen
|
- Secrets können über Logs, Prompts, Screenshots oder Git abfließen
|
||||||
|
- Cloudrouting könnte sensible Daten preisgeben; vor jedem Cloudaufruf ist deshalb
|
||||||
|
lokal `allowed`, `ask` oder `never` zu entscheiden und Kontext zu minimieren
|
||||||
|
- kostenlose Gemini-Kontingente und Modellnamen können sich ändern
|
||||||
- Prompt Injection aus später eingelesenen Notizen oder Webseiten
|
- Prompt Injection aus später eingelesenen Notizen oder Webseiten
|
||||||
- unkontrollierter Obsidian-Schreibzugriff könnte Wissen beschädigen
|
- unkontrollierter Obsidian-Schreibzugriff könnte Wissen beschädigen
|
||||||
- Self-hosted LiveSync ist kein Ersatz für ein getestetes Backup
|
- Self-hosted LiveSync ist kein Ersatz für ein getestetes Backup
|
||||||
@@ -181,10 +288,11 @@ CLI-/Persistenz-Smoke:
|
|||||||
|
|
||||||
## Nächster sinnvoller Auftrag
|
## Nächster sinnvoller Auftrag
|
||||||
|
|
||||||
Nach Merge von `feat/core-chat` den Chat gemeinsam im Alltag benutzen und zuerst
|
Desktop-MVP nach `docs/DESKTOP_UI.md` manuell durch Pascal abnehmen. Danach
|
||||||
konkrete Bedienungs- oder Stabilitätsprobleme sammeln. Mögliche kleine Folgepakete
|
stoppen; vor dieser Abnahme nicht nach `main` mergen und keine Folgephase beginnen.
|
||||||
sind ein klarer Start-/Stoppablauf, Streaming, Sitzungstitel oder eine explizite
|
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
|
||||||
lokale TOML-Konfiguration. Das spätere Laptop-Modell wird erst auf dessen Hardware
|
Noch keine Obsidian-Integration oder Tools beginnen.
|
||||||
gemessen und separat entschieden.
|
|
||||||
|
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
|
||||||
|
|
||||||
Nicht Teil dieses Meilensteins: Obsidian-Integration, Serverzugriff, Sprache, Mobile Client oder Living-Mind-Code.
|
Nicht Teil dieses Meilensteins: Obsidian-Integration, Serverzugriff, Sprache, Mobile Client oder Living-Mind-Code.
|
||||||
|
|||||||
+4
-1
@@ -8,8 +8,11 @@
|
|||||||
| ADR-0004 | Tokenbewusst arbeiten, ohne Qualität oder Sicherheit zu senken | angenommen |
|
| ADR-0004 | Tokenbewusst arbeiten, ohne Qualität oder Sicherheit zu senken | angenommen |
|
||||||
| ADR-0005 | Laufenden Handoff kompakt halten und Vollstände nur zu Meilensteinen archivieren | angenommen |
|
| ADR-0005 | Laufenden Handoff kompakt halten und Vollstände nur zu Meilensteinen archivieren | angenommen |
|
||||||
| 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 |
|
||||||
|
| UI-0001 | Desktop-MVP verwendet PySide6 und denselben Javis-Kern wie die CLI | 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).
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Javis Desktop
|
||||||
|
|
||||||
|
## Installation und Start
|
||||||
|
|
||||||
|
Die Desktopoberfläche verwendet PySide6 und denselben Python-Kern wie der CLI-Chat.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& 'D:\Javis-Tools\uv\uv.exe' sync --dev
|
||||||
|
& 'D:\Javis-Tools\uv\uv.exe' run javis gui
|
||||||
|
```
|
||||||
|
|
||||||
|
Der empfohlene Windows-Start prüft zusätzlich Repository, lokale Werkzeuge und
|
||||||
|
Ollama und setzt ausschließlich Prozessvariablen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\start-javis-gui.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls Windows den direkten Skriptaufruf per Richtlinie blockiert, funktioniert
|
||||||
|
ohne systemweite Änderung der prozesslokale Start:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\start-javis-gui.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Der bisherige Start über `.\scripts\start-javis.ps1` beziehungsweise
|
||||||
|
`uv run javis chat` bleibt unverändert verfügbar.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
- `Enter`: Nachricht senden
|
||||||
|
- `Shift+Enter`: neue Zeile
|
||||||
|
- `Ctrl+N`: neue Sitzung
|
||||||
|
- `Ctrl+K`: Sitzungssuche fokussieren
|
||||||
|
- `Esc`: laufende Antwort abbrechen
|
||||||
|
- Doppelklick: Sitzung laden
|
||||||
|
- Kontextmenü einer Sitzung: umbenennen
|
||||||
|
|
||||||
|
Die linke Spalte verwaltet Sitzungen. In der Mitte erscheinen Verlauf,
|
||||||
|
Markdownantwort und Eingabe. Rechts stehen nur bereinigte technische Metadaten:
|
||||||
|
Zustand, Modus, Provider, Ollama-Erreichbarkeit, Modell, Datenschutzentscheidung,
|
||||||
|
Cloudzähler, Fallback und Datenverzeichnis.
|
||||||
|
|
||||||
|
## Provider und Datenschutz
|
||||||
|
|
||||||
|
`Hybrid/Auto` verwendet weiterhin den vorhandenen lokalen Datenschutzrouter.
|
||||||
|
`Lokal` verhindert Cloudnutzung. Bei `ask` stehen drei Entscheidungen bereit:
|
||||||
|
|
||||||
|
- `Einmal erlauben`: minimierten notwendigen Kontext einmalig an Gemini senden
|
||||||
|
- `Lokal beantworten`: ohne Cloud mit Ollama antworten
|
||||||
|
- `Abbrechen`: Anfrage ohne Speicherung beenden
|
||||||
|
|
||||||
|
`never` wird ausschließlich lokal verarbeitet und zeigt keinen Freigabedialog.
|
||||||
|
Der Gemini-Key bleibt im Betriebssystem-Keyring und wird in der Oberfläche weder
|
||||||
|
ausgegeben noch dokumentiert.
|
||||||
|
|
||||||
|
## Streaming und Abbruch
|
||||||
|
|
||||||
|
Die Modellgenerierung läuft in einem Qt-Worker. Es kann nur eine Antwort
|
||||||
|
gleichzeitig laufen. `Stoppen`, `Esc` oder kontrolliertes Schließen fordern den
|
||||||
|
Abbruch an. Ein unvollständiges Benutzer-/Assistentenpaar wird nicht gespeichert.
|
||||||
|
|
||||||
|
## Bekannte Grenzen
|
||||||
|
|
||||||
|
- keine Sprache, kein Mikrofon und kein Wakeword
|
||||||
|
- keine Betriebssystem-, App-, Obsidian-, Server- oder Laptopsteuerung
|
||||||
|
- keine autonomen Aktionen und keine neuen Cloudanbieter
|
||||||
|
- Schließen kann kurz warten, bis der lokale Netzwerkstream kontrolliert reagiert
|
||||||
|
- medizinische Antwortqualität ist ein späteres Sicherheits-/Systemprompt-Thema
|
||||||
|
|
||||||
|
## Manueller Abnahmetest für Pascal
|
||||||
|
|
||||||
|
1. `.\scripts\start-javis-gui.ps1`
|
||||||
|
2. Prüfen, dass sich das Fenster öffnet.
|
||||||
|
3. Eine lokale Frage stellen.
|
||||||
|
4. Die gestreamte Antwort prüfen.
|
||||||
|
5. Eine neue Sitzung anlegen.
|
||||||
|
6. Die alte Sitzung laden.
|
||||||
|
7. Eine Antwort mit `Esc` oder `Stoppen` abbrechen.
|
||||||
|
8. Den Modus `Lokal` testen.
|
||||||
|
9. Eine öffentliche Technikfrage im Hybridmodus testen.
|
||||||
|
10. Eine `ask`-Anfrage lokal beantworten lassen.
|
||||||
|
11. Anwendung schließen und neu starten.
|
||||||
|
12. Die gespeicherte Sitzung erneut laden.
|
||||||
|
|
||||||
|
Abnahmestatus: automatisierte Tests und echter lokaler GUI-Smoke durch Codex
|
||||||
|
bestanden; manuelle Abnahme durch Pascal steht noch aus.
|
||||||
+18
-10
@@ -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.
|
||||||
|
|||||||
@@ -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
-16
@@ -1,25 +1,26 @@
|
|||||||
# Nächste Sitzung
|
# Nächste Sitzung
|
||||||
|
|
||||||
## Ziel
|
## Ausgangsstand
|
||||||
|
|
||||||
Nach Abschluss und Merge von `feat/core-chat`: den ersten Textchat gemeinsam
|
Der Core-Chat, Gemini-/Datenschutz-Meilenstein und das Chatkomfort-Paket sind
|
||||||
benutzen und nur konkrete Bedienungs- oder Stabilitätsprobleme priorisieren.
|
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`
|
Das Desktop-MVP auf `feat/desktop-ui` verwendet denselben Kern wie die CLI.
|
||||||
2. `git log -1 --oneline`
|
101 Tests, Qt-Offscreen-Start und echter lokaler Ollama-GUI-Smoke sind erfolgreich.
|
||||||
3. `docs/PROJECT_STATUS.md`
|
Die manuelle Abnahme durch Pascal steht noch aus.
|
||||||
4. offene Entscheidungen in `docs/DECISIONS.md`
|
|
||||||
|
|
||||||
## Vorgeschlagener Umfang
|
## Nächster sinnvoller Schritt
|
||||||
|
|
||||||
- Start-/Stoppablauf von Ollama auf Alltagstauglichkeit prüfen
|
Nur den Desktop-MVP nach `docs/DESKTOP_UI.md` manuell abnehmen. Erst danach mit
|
||||||
- reale mehrteilige Gespräche und verständliche Fehlermeldungen beobachten
|
Pascal ein einzelnes nächstes Paket festlegen; keine Folgephase automatisch beginnen.
|
||||||
- bei Bedarf Streaming, Sitzungstitel oder eine explizite lokale TOML-Konfiguration
|
|
||||||
als eigenes kleines Feature planen
|
|
||||||
- erst nach Messung auf der Zielhardware ein kleineres Laptop-Modell auswählen
|
|
||||||
|
|
||||||
## Nicht beginnen
|
## Weiterhin nicht beginnen
|
||||||
|
|
||||||
Keine zweite Modelllaufzeit, kein Obsidian-Schreiben, kein SSH-/Serverzugriff, keine Sprache und kein Living-Mind-Frontend.
|
Noch keine Obsidian-Integration, Tools, SSH-/Serversteuerung, Sprache, Mobile
|
||||||
|
Client oder Living Mind. Der vorhandene Gemini-Key bleibt im Betriebssystem-
|
||||||
|
Keyring und darf nicht erneut in Chat, Logs oder Dokumentation erscheinen.
|
||||||
|
|||||||
+103
-13
@@ -1,6 +1,6 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
Stand: lokaler Textchat auf `feat/core-chat`, 30.07.2026.
|
Stand: Desktop-MVP automatisiert und mit lokalem Modell geprüft, 30.07.2026.
|
||||||
|
|
||||||
## Erreicht
|
## Erreicht
|
||||||
|
|
||||||
@@ -20,28 +20,118 @@ Stand: lokaler Textchat auf `feat/core-chat`, 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
|
- 101 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.
|
||||||
|
|
||||||
|
## Desktop-MVP
|
||||||
|
|
||||||
|
- PySide6-Client verwendet Runtime, Chatservice, Hybridprovider, Datenschutz,
|
||||||
|
SQLite, Audit und Secret-Provider des bestehenden Kerns
|
||||||
|
- Sitzungen, Suche, Umbenennen, Markdown, Streaming, Abbruch, Modus,
|
||||||
|
Dreifach-Freigabedialog und bereinigter Status implementiert
|
||||||
|
- `uv run javis gui` und `scripts/start-javis-gui.ps1` vorhanden
|
||||||
|
- Qt-Offscreen-Start und echter lokaler Ollama-GUI-Smoke bestanden
|
||||||
|
- automatisiert durch Codex getestet: ja
|
||||||
|
- echter lokaler GUI-Smoke durch Codex: ja; 1 Sitzung, 2 Nachrichten, Ollama
|
||||||
|
- manuell durch Pascal abgenommen: noch nicht
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
- Branch: `feat/core-chat`
|
- aktueller Branch: `feat/desktop-ui`
|
||||||
- Grundgerüstcommit: `6d04171`
|
- Desktop-Basis: `main` bei `d3e85f2`
|
||||||
- Basis des Feature-Branches: `e6eb995`
|
- GUI-Grundgerüst: `1658e04`
|
||||||
- Toolchain: `fa90a5c`
|
- Sitzungen/Streaming/Datenschutz: `cf35148`
|
||||||
- Laufzeit/Modell: `10d7cd7`
|
- Tests/Startskript/Dokumentation: `571969b`
|
||||||
- Chatkern: `ec9b2f9`
|
- `feat/desktop-ui` zu Gitea gepusht und synchron verifiziert
|
||||||
- Tests: `b153daa`
|
- Basis: `main` bei `77b510b`
|
||||||
|
- medizinisches Routing: `9148193`
|
||||||
|
- Streaming: `00a5a6c`
|
||||||
|
- Titelmigration: `112ae6d`
|
||||||
|
- Sitzungsnavigation: `43c1f76`
|
||||||
|
- Dokumentation: `328dcd9`
|
||||||
|
- Feature-Branch zu `origin/feat/chat-comfort` gepusht
|
||||||
|
- Merge nach `main`: `cbba102`; 91 Tests und Ruff auf `main` erneut bestanden
|
||||||
|
- `main` bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
|
||||||
|
- vorheriger Gemini-/Datenschutz-Merge auf `main`: `564a3fd`
|
||||||
|
|
||||||
## 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.
|
||||||
|
Der Gemini-Key liegt ausschließlich im Betriebssystem-Keyring; sein Wert wurde
|
||||||
|
von Codex weder gelesen noch ausgegeben. Pascal hat den echten Cloud- und
|
||||||
|
Fallbackablauf erfolgreich geprüft.
|
||||||
|
|||||||
+14
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ADR-0007: Kostenlose hybride Providerstrategie
|
||||||
|
|
||||||
|
Status: angenommen am 30.07.2026, Implementierung ausstehend.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Javis bleibt in Logik, Gedächtnis, Berechtigungen, Audit und Kontextauswahl lokal.
|
||||||
|
Für ausdrücklich cloudgeeignete Inhalte soll Gemini später der bevorzugte
|
||||||
|
kostenlose Provider werden. Ollama bleibt lokaler Datenschutz-, Offline-, Ausfall-
|
||||||
|
und Quota-Fallback. `free_only = true` verbietet Billing-Aktivierung,
|
||||||
|
kostenpflichtige Modellwahl und kostenpflichtige Fallbacks.
|
||||||
|
|
||||||
|
Vor jedem Cloudaufruf entscheidet eine lokale Richtlinie:
|
||||||
|
|
||||||
|
- `allowed`: minimal nötiger Ausschnitt darf übertragen werden
|
||||||
|
- `ask`: Pascal muss vorher zustimmen
|
||||||
|
- `never`: ausschließlich lokal verarbeiten
|
||||||
|
|
||||||
|
Persönliche und familiäre Informationen sind mindestens `ask`. Secrets sowie
|
||||||
|
Finanz-, Gesundheits- und besonders sensible Daten sind `never`. Der vollständige
|
||||||
|
Obsidian-Vault darf niemals an Gemini übertragen werden.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
Vor der Implementierung werden aktuelle kostenlose Modelle, exakte Modellnamen und
|
||||||
|
Kontingente anhand offizieller Google-Dokumentation geprüft. Ein Gemini-Schlüssel
|
||||||
|
wird nur über einen lokalen Secret-Provider geladen, auf die Gemini API begrenzt
|
||||||
|
und niemals an Modellkontext, Git, Obsidian, Logs, Dokumentation oder Chat gegeben.
|
||||||
|
Javis verbindet oder aktiviert kein Google-Billingkonto.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
|
||||||
|
HTTP 429, Quota- und Netzwerkausfälle müssen verständlich behandelt und sicher zum
|
||||||
|
lokalen Provider geroutet werden. Gemini ist bevorzugt, aber nie unersetzliche
|
||||||
|
Grundlage. Die Umsetzung erfolgt als eigenes getestetes Featurepaket; der bereits
|
||||||
|
abgenommene lokale Chat bleibt bis dahin unverändert.
|
||||||
@@ -6,3 +6,4 @@
|
|||||||
- [ADR-0004: Tokenbewusster Arbeitsablauf](0004-token-efficient-workflow.md)
|
- [ADR-0004: Tokenbewusster Arbeitsablauf](0004-token-efficient-workflow.md)
|
||||||
- [ADR-0005: Kompakter Handoff](0005-compact-handoff.md)
|
- [ADR-0005: Kompakter Handoff](0005-compact-handoff.md)
|
||||||
- [ADR-0006: Erste lokale Modelllaufzeit](0006-first-local-runtime.md)
|
- [ADR-0006: Erste lokale Modelllaufzeit](0006-first-local-runtime.md)
|
||||||
|
- [ADR-0007: Kostenlose hybride Providerstrategie](0007-free-hybrid-providers.md)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Fatal Python error: Aborted
|
||||||
|
|
||||||
|
Current thread 0x000066dc (most recent call first):
|
||||||
|
File "D:\Javis-Projekt\src\javis\ui\main_window.py", line 301 in _generation_complete
|
||||||
|
File "D:\Javis-Projekt\src\javis\ui\app.py", line 44 in run_gui
|
||||||
|
File "D:\Javis-Projekt\src\javis\interface\cli.py", line 429 in main
|
||||||
|
File "D:\Javis-Projekt\.venv\Scripts\javis.exe\__main__.py", line 10 in <module>
|
||||||
|
File "<frozen runpy>", line 88 in _run_code
|
||||||
|
File "<frozen runpy>", line 198 in _run_module_as_main
|
||||||
|
|
||||||
|
Extension modules: charset_normalizer.md, charset_normalizer.cd, requests.packages.charset_normalizer.md, requests.packages.chardet.md, requests.packages.charset_normalizer.cd, requests.packages.chardet.cd, _cffi_backend, websockets.speedups, shiboken6.Shiboken, PySide6.QtCore, PySide6.QtGui, PySide6.QtWidgets (total: 12)
|
||||||
|
QThreadStorage: entry 1 destroyed before end of thread 0x28a793bea70
|
||||||
|
QThreadStorage: entry 0 destroyed before end of thread 0x28a793bea70
|
||||||
+6
-1
@@ -8,13 +8,18 @@ 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",
|
||||||
|
"pyside6>=6.8,<7",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
javis = "javis.interface.cli:main"
|
javis = "javis.interface.cli:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"pytest>=8,<9",
|
||||||
"ruff>=0.12,<1",
|
"ruff>=0.12,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$UvPath,
|
||||||
|
[string]$OllamaPath,
|
||||||
|
[string]$OllamaModels,
|
||||||
|
[string]$DataDir,
|
||||||
|
[string]$UvCacheDir,
|
||||||
|
[string]$PythonInstallDir
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$env:JAVIS_START_INTERFACE = 'gui'
|
||||||
|
|
||||||
|
$sharedStarter = Join-Path $PSScriptRoot 'start-javis.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $sharedStarter -PathType Leaf)) {
|
||||||
|
throw "Das gemeinsame sichere Javis-Startskript wurde nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
& $sharedStarter @PSBoundParameters
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
[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 {
|
||||||
|
if ($env:JAVIS_START_INTERFACE -eq 'gui') {
|
||||||
|
& $resolvedUv run javis gui
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
& $resolvedUv run javis chat
|
||||||
|
}
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
"""Local privacy and zero-cost routing between Gemini and Ollama."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalChoice(StrEnum):
|
||||||
|
ALLOW = "allow"
|
||||||
|
LOCAL = "local"
|
||||||
|
CANCEL = "cancel"
|
||||||
|
|
||||||
|
|
||||||
|
ApprovalCallback = Callable[[PrivacyDecision], ApprovalChoice | 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._approval_lock = threading.Lock()
|
||||||
|
self._next_approval: ApprovalChoice | None = None
|
||||||
|
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 privacy_decision(self, text: str) -> PrivacyDecision:
|
||||||
|
"""Classify a prospective request without contacting a provider."""
|
||||||
|
return self._privacy_router.classify(
|
||||||
|
text,
|
||||||
|
cloud_requested=self.mode == "gemini",
|
||||||
|
)
|
||||||
|
|
||||||
|
def queue_approval(self, choice: ApprovalChoice) -> None:
|
||||||
|
"""Provide the GUI decision for exactly the next ASK request."""
|
||||||
|
if choice not in {ApprovalChoice.ALLOW, ApprovalChoice.LOCAL}:
|
||||||
|
raise ValueError("Nur Freigabe oder lokale Antwort kann vorgemerkt werden.")
|
||||||
|
with self._approval_lock:
|
||||||
|
self._next_approval = choice
|
||||||
|
|
||||||
|
def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice:
|
||||||
|
with self._approval_lock:
|
||||||
|
queued = self._next_approval
|
||||||
|
self._next_approval = None
|
||||||
|
if queued is not None:
|
||||||
|
return queued
|
||||||
|
result = self._approval_callback(decision)
|
||||||
|
if isinstance(result, bool):
|
||||||
|
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
|
||||||
|
if isinstance(result, ApprovalChoice):
|
||||||
|
return result
|
||||||
|
raise ValueError("Ungültiges Ergebnis der Cloudfreigabe.")
|
||||||
|
|
||||||
|
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:
|
||||||
|
choice = self._approval_choice(decision)
|
||||||
|
if choice is ApprovalChoice.CANCEL:
|
||||||
|
raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.")
|
||||||
|
approved = choice is ApprovalChoice.ALLOW
|
||||||
|
if choice is ApprovalChoice.LOCAL:
|
||||||
|
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:
|
||||||
|
choice = self._approval_choice(decision)
|
||||||
|
if choice is ApprovalChoice.CANCEL:
|
||||||
|
raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.")
|
||||||
|
approved = choice is ApprovalChoice.ALLOW
|
||||||
|
if choice is ApprovalChoice.LOCAL:
|
||||||
|
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."
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Shared runtime assembly for CLI and desktop interfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from javis.config.settings import Settings
|
||||||
|
from javis.core.chat_service import ChatService
|
||||||
|
from javis.core.provider_router import ApprovalCallback, HybridProvider, NoticeCallback
|
||||||
|
from javis.memory.sqlite_store import SQLiteSessionStore
|
||||||
|
from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError
|
||||||
|
from javis.providers.base import MissingApiKeyError
|
||||||
|
from javis.providers.gemini import GeminiProvider
|
||||||
|
from javis.providers.ollama import OllamaProvider
|
||||||
|
from javis.security.privacy import PrivacyRouter
|
||||||
|
from javis.security.secrets import SecretProvider, SecretStoreError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeStatus:
|
||||||
|
ollama_reachable: bool
|
||||||
|
local_model_available: bool
|
||||||
|
local_model: str
|
||||||
|
gemini_key_available: bool
|
||||||
|
gemini_configured: bool
|
||||||
|
free_only: bool
|
||||||
|
provider_mode: str
|
||||||
|
cloud_requests_today: int | None
|
||||||
|
local_fallbacks_today: int | None
|
||||||
|
data_dir: str
|
||||||
|
|
||||||
|
def cli_lines(self) -> list[str]:
|
||||||
|
cloud_requests = (
|
||||||
|
self.cloud_requests_today if self.cloud_requests_today is not None else "unbekannt"
|
||||||
|
)
|
||||||
|
local_fallbacks = (
|
||||||
|
self.local_fallbacks_today if self.local_fallbacks_today is not None else "unbekannt"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
f"Ollama erreichbar: {'ja' if self.ollama_reachable else 'nein'}",
|
||||||
|
f"Lokales Modell vorhanden: {'ja' if self.local_model_available else 'nein'}",
|
||||||
|
f"Gemini-Schlüssel vorhanden: {'ja' if self.gemini_key_available else 'nein'}",
|
||||||
|
f"Gemini konfiguriert: {'ja' if self.gemini_configured else 'nein'}",
|
||||||
|
f"Nullkostenmodus: {'aktiv' if self.free_only else 'inaktiv'}",
|
||||||
|
f"Aktiver Providermodus: {self.provider_mode}",
|
||||||
|
f"Heutige Cloudanfragen: {cloud_requests}",
|
||||||
|
f"Heutige lokale Fallbacks: {local_fallbacks}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class JavisRuntime:
|
||||||
|
settings: Settings
|
||||||
|
service: ChatService
|
||||||
|
local_provider: OllamaProvider
|
||||||
|
hybrid_provider: HybridProvider
|
||||||
|
usage_store: SQLiteUsageStore
|
||||||
|
secrets: SecretProvider
|
||||||
|
|
||||||
|
def status(self) -> RuntimeStatus:
|
||||||
|
ollama = self.local_provider.probe()
|
||||||
|
try:
|
||||||
|
key_available = self.secrets.gemini_key_available()
|
||||||
|
except SecretStoreError:
|
||||||
|
key_available = False
|
||||||
|
try:
|
||||||
|
cloud_requests: int | None = self.usage_store.cloud_requests_on()
|
||||||
|
local_fallbacks: int | None = self.usage_store.local_fallbacks_on()
|
||||||
|
except UsageStoreError:
|
||||||
|
cloud_requests = local_fallbacks = None
|
||||||
|
return RuntimeStatus(
|
||||||
|
ollama_reachable=ollama.reachable,
|
||||||
|
local_model_available=ollama.model_available,
|
||||||
|
local_model=self.local_provider.model,
|
||||||
|
gemini_key_available=key_available,
|
||||||
|
gemini_configured=(
|
||||||
|
self.settings.gemini_enabled
|
||||||
|
and self.settings.free_only
|
||||||
|
and self.settings.billing_confirmed_disabled
|
||||||
|
),
|
||||||
|
free_only=self.settings.free_only,
|
||||||
|
provider_mode=self.hybrid_provider.mode,
|
||||||
|
cloud_requests_today=cloud_requests,
|
||||||
|
local_fallbacks_today=local_fallbacks,
|
||||||
|
data_dir=str(self.settings.data_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_javis_runtime(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
approval_callback: ApprovalCallback,
|
||||||
|
notice_callback: NoticeCallback,
|
||||||
|
secret_provider: SecretProvider | None = None,
|
||||||
|
) -> JavisRuntime:
|
||||||
|
"""Assemble the existing core once for any user interface."""
|
||||||
|
|
||||||
|
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 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=approval_callback,
|
||||||
|
notice_callback=notice_callback,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return JavisRuntime(
|
||||||
|
settings=settings,
|
||||||
|
service=ChatService(store, hybrid),
|
||||||
|
local_provider=local,
|
||||||
|
hybrid_provider=hybrid,
|
||||||
|
usage_store=usage_store,
|
||||||
|
secrets=secrets,
|
||||||
|
)
|
||||||
+289
-17
@@ -3,28 +3,42 @@
|
|||||||
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.runtime import build_javis_runtime
|
||||||
from javis.memory.sqlite_store import (
|
from javis.memory.sqlite_store import (
|
||||||
|
AmbiguousSessionReferenceError,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
SessionStoreError,
|
SessionStoreError,
|
||||||
SQLiteSessionStore,
|
|
||||||
)
|
)
|
||||||
|
from javis.memory.usage_store import UsageStoreError
|
||||||
from javis.providers.base import ProviderError
|
from javis.providers.base import ProviderError
|
||||||
from javis.providers.ollama import OllamaProvider
|
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 +48,83 @@ 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")
|
||||||
|
subparsers.add_parser("gui", help="Javis-Desktopoberfläche starten")
|
||||||
|
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 +139,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 +208,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 +234,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 +268,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 +337,68 @@ 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]:
|
||||||
|
def approve(_decision: object) -> bool:
|
||||||
|
answer = input_fn("Cloudfreigabe erforderlich. Einmalig erlauben? [j/N] ")
|
||||||
|
return _confirmed(answer)
|
||||||
|
|
||||||
|
runtime = build_javis_runtime(
|
||||||
|
settings,
|
||||||
|
approval_callback=approve,
|
||||||
|
notice_callback=output,
|
||||||
|
secret_provider=secret_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
def status() -> list[str]:
|
||||||
|
return runtime.status().cli_lines()
|
||||||
|
|
||||||
|
return runtime.service, status
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(
|
||||||
argv: Sequence[str] | None = None,
|
argv: Sequence[str] | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -155,26 +406,47 @@ 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 == "gui":
|
||||||
|
from javis.ui.app import run_gui
|
||||||
|
|
||||||
|
return run_gui([])
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""PySide6 desktop interface for the existing Javis core."""
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Desktop application entry point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
|
from javis.config.settings import ConfigurationError, Settings
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
|
from javis.core.runtime import build_javis_runtime
|
||||||
|
from javis.memory.sqlite_store import SessionStoreError
|
||||||
|
from javis.memory.usage_store import UsageStoreError
|
||||||
|
from javis.ui.chat_controller import DesktopController
|
||||||
|
from javis.ui.main_window import MainWindow
|
||||||
|
from javis.ui.workers import ApprovalBridge
|
||||||
|
|
||||||
|
|
||||||
|
def run_gui(argv: Sequence[str] | None = None) -> int:
|
||||||
|
application = QApplication.instance() or QApplication(list(argv or []))
|
||||||
|
application.setApplicationName("Javis Desktop")
|
||||||
|
approval_bridge = ApprovalBridge()
|
||||||
|
try:
|
||||||
|
settings = Settings.from_env()
|
||||||
|
runtime = build_javis_runtime(
|
||||||
|
settings,
|
||||||
|
approval_callback=lambda _decision: ApprovalChoice.CANCEL,
|
||||||
|
notice_callback=approval_bridge.notice_received.emit,
|
||||||
|
)
|
||||||
|
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
|
||||||
|
QMessageBox.critical(None, "Javis konnte nicht starten", str(exc))
|
||||||
|
return 2
|
||||||
|
window = MainWindow(DesktopController(runtime), approval_bridge)
|
||||||
|
window.show()
|
||||||
|
smoke_exit = os.environ.get("JAVIS_GUI_SMOKE_EXIT_MS")
|
||||||
|
if smoke_exit:
|
||||||
|
try:
|
||||||
|
delay = max(1, min(int(smoke_exit), 10_000))
|
||||||
|
except ValueError:
|
||||||
|
delay = 500
|
||||||
|
QTimer.singleShot(delay, application.quit)
|
||||||
|
return application.exec()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
return run_gui(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Small non-Qt adapter between desktop widgets and the existing Javis core."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
|
from javis.core.runtime import JavisRuntime
|
||||||
|
from javis.memory.sqlite_store import ChatSession
|
||||||
|
from javis.providers.base import ProviderError, ResponseAbortedError
|
||||||
|
from javis.security.privacy import CloudPolicy, PrivacyDecision
|
||||||
|
from javis.ui.models import (
|
||||||
|
DesktopStatus,
|
||||||
|
GenerationState,
|
||||||
|
StreamEvent,
|
||||||
|
StreamEventKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SECRET_PATTERN = re.compile(
|
||||||
|
r"AIza[\w-]{20,}|sk-[\w-]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitized_error(error: BaseException) -> str:
|
||||||
|
text = str(error).strip() or "Unbekannter Fehler."
|
||||||
|
return _SECRET_PATTERN.sub("[SECRET MASKIERT]", text)
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopController:
|
||||||
|
"""Expose core operations without placing business rules in widgets."""
|
||||||
|
|
||||||
|
def __init__(self, runtime: JavisRuntime) -> None:
|
||||||
|
self.runtime = runtime
|
||||||
|
self.service = runtime.service
|
||||||
|
self.active_session_id: str | None = None
|
||||||
|
self.state = GenerationState.READY
|
||||||
|
|
||||||
|
def ensure_session(self) -> ChatSession:
|
||||||
|
if self.active_session_id is None:
|
||||||
|
session = self.service.new_session()
|
||||||
|
self.active_session_id = session.id
|
||||||
|
return session
|
||||||
|
return self.service.load_session(self.active_session_id).session
|
||||||
|
|
||||||
|
def new_session(self) -> ChatSession:
|
||||||
|
session = self.service.new_session()
|
||||||
|
self.active_session_id = session.id
|
||||||
|
return session
|
||||||
|
|
||||||
|
def list_sessions(self, search: str = "") -> list[ChatSession]:
|
||||||
|
return (
|
||||||
|
self.service.search_sessions(search) if search.strip() else self.service.list_sessions()
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_session(self, session_id: str):
|
||||||
|
loaded = self.service.load_session(session_id)
|
||||||
|
self.active_session_id = loaded.session.id
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
def rename_active_session(self, title: str) -> ChatSession:
|
||||||
|
session = self.ensure_session()
|
||||||
|
return self.service.rename_session(session.id, title)
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
self.runtime.hybrid_provider.set_mode(mode)
|
||||||
|
|
||||||
|
def approval_decision(self, text: str) -> PrivacyDecision | None:
|
||||||
|
provider = self.runtime.hybrid_provider
|
||||||
|
decision = provider.privacy_decision(text)
|
||||||
|
if provider.mode == "local" or decision.policy is not CloudPolicy.ASK:
|
||||||
|
return None
|
||||||
|
return decision
|
||||||
|
|
||||||
|
def queue_approval(self, choice: ApprovalChoice) -> None:
|
||||||
|
self.runtime.hybrid_provider.queue_approval(choice)
|
||||||
|
|
||||||
|
def stream_message(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
cancel_event: threading.Event,
|
||||||
|
) -> Iterator[StreamEvent]:
|
||||||
|
session = self.ensure_session()
|
||||||
|
stream = self.service.stream_send(session.id, text)
|
||||||
|
self.state = GenerationState.THINKING
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
started = False
|
||||||
|
try:
|
||||||
|
for chunk in stream:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
stream.close()
|
||||||
|
self.state = GenerationState.ABORTED
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
return
|
||||||
|
if not started:
|
||||||
|
started = True
|
||||||
|
self.state = GenerationState.ANSWERING
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
yield StreamEvent(StreamEventKind.CHUNK, text=chunk)
|
||||||
|
except ResponseAbortedError:
|
||||||
|
self.state = GenerationState.ABORTED
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
return
|
||||||
|
except ProviderError as exc:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
self.state = GenerationState.ABORTED
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
return
|
||||||
|
self.state = GenerationState.ERROR
|
||||||
|
yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self.state = GenerationState.ERROR
|
||||||
|
yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state)
|
||||||
|
return
|
||||||
|
self.state = GenerationState.READY
|
||||||
|
yield StreamEvent(StreamEventKind.FINISHED, state=self.state)
|
||||||
|
|
||||||
|
def status(self) -> DesktopStatus:
|
||||||
|
runtime_status = self.runtime.status()
|
||||||
|
route = self.runtime.hybrid_provider.last_route
|
||||||
|
state = self.state
|
||||||
|
if state is GenerationState.READY and not runtime_status.ollama_reachable:
|
||||||
|
state = GenerationState.OFFLINE
|
||||||
|
return DesktopStatus(
|
||||||
|
state=state,
|
||||||
|
mode="Lokal" if runtime_status.provider_mode == "local" else "Hybrid/Auto",
|
||||||
|
provider=route.provider if route else "—",
|
||||||
|
ollama_reachable=runtime_status.ollama_reachable,
|
||||||
|
local_model=runtime_status.local_model,
|
||||||
|
privacy_policy=route.privacy_policy.value if route else "—",
|
||||||
|
cloud_requests_today=runtime_status.cloud_requests_today,
|
||||||
|
local_fallback=bool(route and route.fallback),
|
||||||
|
data_dir=runtime_status.data_dir,
|
||||||
|
)
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
"""Main PySide6 window for the Javis desktop MVP."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
from PySide6.QtCore import QSignalBlocker, Qt, QThread, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QAction, QCloseEvent, QKeySequence, QShortcut
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QMainWindow,
|
||||||
|
QMenu,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSplitter,
|
||||||
|
QTextBrowser,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
|
from javis.providers.base import ChatMessage
|
||||||
|
from javis.security.privacy import PrivacyDecision
|
||||||
|
from javis.ui.chat_controller import DesktopController
|
||||||
|
from javis.ui.models import GenerationState, StreamEvent, StreamEventKind
|
||||||
|
from javis.ui.workers import ApprovalBridge, StreamWorker
|
||||||
|
|
||||||
|
|
||||||
|
class MessageEdit(QTextEdit):
|
||||||
|
send_requested = Signal()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event) -> None:
|
||||||
|
if event.key() in {Qt.Key_Return, Qt.Key_Enter} and not (
|
||||||
|
event.modifiers() & Qt.ShiftModifier
|
||||||
|
):
|
||||||
|
self.send_requested.emit()
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
controller: DesktopController,
|
||||||
|
approval_bridge: ApprovalBridge,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.controller = controller
|
||||||
|
self.approval_bridge = approval_bridge
|
||||||
|
self._thread: QThread | None = None
|
||||||
|
self._worker: StreamWorker | None = None
|
||||||
|
self._generation_finalized = True
|
||||||
|
self._close_when_finished = False
|
||||||
|
self._messages: list[ChatMessage] = []
|
||||||
|
self._draft_user = ""
|
||||||
|
self._draft_assistant = ""
|
||||||
|
self._thinking_dots = 0
|
||||||
|
self._build_ui()
|
||||||
|
self._connect_signals()
|
||||||
|
self._apply_style()
|
||||||
|
self._open_initial_session()
|
||||||
|
self._animation = QTimer(self)
|
||||||
|
self._animation.timeout.connect(self._animate_state)
|
||||||
|
self._animation.start(450)
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
self.setWindowTitle("Javis Desktop")
|
||||||
|
self.resize(1360, 820)
|
||||||
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
splitter.addWidget(self._build_sidebar())
|
||||||
|
splitter.addWidget(self._build_chat())
|
||||||
|
splitter.addWidget(self._build_status_panel())
|
||||||
|
splitter.setSizes([280, 760, 300])
|
||||||
|
self.setCentralWidget(splitter)
|
||||||
|
self.statusBar().showMessage("Bereit")
|
||||||
|
|
||||||
|
QShortcut(QKeySequence("Ctrl+N"), self, activated=self._new_session)
|
||||||
|
QShortcut(QKeySequence("Ctrl+K"), self, activated=self._focus_search)
|
||||||
|
QShortcut(QKeySequence("Esc"), self, activated=self._stop_generation)
|
||||||
|
|
||||||
|
def _build_sidebar(self) -> QWidget:
|
||||||
|
panel = QWidget()
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
self.new_button = QPushButton("Neue Sitzung")
|
||||||
|
self.search_edit = QLineEdit()
|
||||||
|
self.search_edit.setPlaceholderText("Sitzungen suchen …")
|
||||||
|
self.session_list = QListWidget()
|
||||||
|
self.session_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
layout.addWidget(self.new_button)
|
||||||
|
layout.addWidget(self.search_edit)
|
||||||
|
layout.addWidget(self.session_list, 1)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _build_chat(self) -> QWidget:
|
||||||
|
panel = QWidget()
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
self.chat_view = QTextBrowser()
|
||||||
|
self.chat_view.setOpenExternalLinks(False)
|
||||||
|
self.message_edit = MessageEdit()
|
||||||
|
self.message_edit.setPlaceholderText("Nachricht an Javis …")
|
||||||
|
self.message_edit.setMaximumHeight(130)
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.addStretch(1)
|
||||||
|
self.stop_button = QPushButton("Stoppen")
|
||||||
|
self.stop_button.setEnabled(False)
|
||||||
|
self.send_button = QPushButton("Senden")
|
||||||
|
buttons.addWidget(self.stop_button)
|
||||||
|
buttons.addWidget(self.send_button)
|
||||||
|
layout.addWidget(self.chat_view, 1)
|
||||||
|
layout.addWidget(self.message_edit)
|
||||||
|
layout.addLayout(buttons)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _build_status_panel(self) -> QWidget:
|
||||||
|
panel = QWidget()
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
title = QLabel("SYSTEMSTATUS")
|
||||||
|
title.setObjectName("panelTitle")
|
||||||
|
layout.addWidget(title)
|
||||||
|
self.state_label = QLabel("Bereit")
|
||||||
|
self.state_label.setObjectName("stateLabel")
|
||||||
|
layout.addWidget(self.state_label)
|
||||||
|
self.mode_combo = QComboBox()
|
||||||
|
self.mode_combo.addItem("Hybrid/Auto", "auto")
|
||||||
|
self.mode_combo.addItem("Lokal", "local")
|
||||||
|
form = QFormLayout()
|
||||||
|
form.addRow("Modus", self.mode_combo)
|
||||||
|
self.status_labels: dict[str, QLabel] = {}
|
||||||
|
for key, label in (
|
||||||
|
("provider", "Provider"),
|
||||||
|
("ollama", "Ollama erreichbar"),
|
||||||
|
("model", "Lokales Modell"),
|
||||||
|
("privacy", "Datenschutz"),
|
||||||
|
("cloud", "Cloudaufrufe heute"),
|
||||||
|
("fallback", "Lokales Fallback"),
|
||||||
|
("data", "Datenverzeichnis"),
|
||||||
|
):
|
||||||
|
value = QLabel("—")
|
||||||
|
value.setWordWrap(True)
|
||||||
|
self.status_labels[key] = value
|
||||||
|
form.addRow(label, value)
|
||||||
|
layout.addLayout(form)
|
||||||
|
layout.addStretch(1)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _connect_signals(self) -> None:
|
||||||
|
self.new_button.clicked.connect(self._new_session)
|
||||||
|
self.search_edit.textChanged.connect(self._refresh_sessions)
|
||||||
|
self.session_list.itemDoubleClicked.connect(self._load_item)
|
||||||
|
self.session_list.customContextMenuRequested.connect(self._session_menu)
|
||||||
|
self.send_button.clicked.connect(self._send)
|
||||||
|
self.stop_button.clicked.connect(self._stop_generation)
|
||||||
|
self.message_edit.send_requested.connect(self._send)
|
||||||
|
self.mode_combo.currentIndexChanged.connect(self._mode_changed)
|
||||||
|
self.approval_bridge.notice_received.connect(self._show_notice)
|
||||||
|
|
||||||
|
def _apply_style(self) -> None:
|
||||||
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
|
QMainWindow, QWidget { background: #171c21; color: #e8edf2; }
|
||||||
|
QLineEdit, QTextEdit, QTextBrowser, QListWidget, QComboBox {
|
||||||
|
background: #202b35; border: 1px solid #344553;
|
||||||
|
border-radius: 6px; padding: 7px; selection-background-color: #b97828;
|
||||||
|
}
|
||||||
|
QPushButton { background: #2b3945; border: 1px solid #485b69;
|
||||||
|
border-radius: 6px; padding: 8px 14px; }
|
||||||
|
QPushButton:hover { border-color: #d3943f; }
|
||||||
|
QPushButton:disabled { color: #68747d; }
|
||||||
|
#panelTitle { color: #d3943f; font-weight: 700; letter-spacing: 1px; }
|
||||||
|
#stateLabel { background: #24342e; color: #86d49b; padding: 10px;
|
||||||
|
border-radius: 6px; font-weight: 700; }
|
||||||
|
QStatusBar { background: #11161a; color: #b8c2ca; }
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _new_session(self) -> None:
|
||||||
|
if self._worker is not None:
|
||||||
|
return
|
||||||
|
session = self.controller.new_session()
|
||||||
|
self._messages = []
|
||||||
|
self._draft_user = self._draft_assistant = ""
|
||||||
|
self._render_chat()
|
||||||
|
self._refresh_sessions()
|
||||||
|
self.statusBar().showMessage(f"Neue Sitzung: {session.title}", 3000)
|
||||||
|
self.message_edit.setFocus()
|
||||||
|
self._refresh_status()
|
||||||
|
|
||||||
|
def _open_initial_session(self) -> None:
|
||||||
|
try:
|
||||||
|
sessions = self.controller.list_sessions()
|
||||||
|
if sessions:
|
||||||
|
loaded = self.controller.load_session(sessions[0].id)
|
||||||
|
self._messages = list(loaded.messages)
|
||||||
|
self._render_chat()
|
||||||
|
self._refresh_sessions()
|
||||||
|
self._refresh_status()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
self._new_session()
|
||||||
|
|
||||||
|
def _refresh_sessions(self) -> None:
|
||||||
|
try:
|
||||||
|
sessions = self.controller.list_sessions(self.search_edit.text())
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
return
|
||||||
|
active = self.controller.active_session_id
|
||||||
|
self.session_list.clear()
|
||||||
|
for session in sessions:
|
||||||
|
item = QListWidgetItem(
|
||||||
|
f"{session.title}\n"
|
||||||
|
f"{session.last_provider} · {session.message_count} Nachrichten · "
|
||||||
|
f"{session.updated_at[:16].replace('T', ' ')}"
|
||||||
|
)
|
||||||
|
item.setData(Qt.UserRole, session.id)
|
||||||
|
self.session_list.addItem(item)
|
||||||
|
if session.id == active:
|
||||||
|
self.session_list.setCurrentItem(item)
|
||||||
|
|
||||||
|
def _load_item(self, item: QListWidgetItem) -> None:
|
||||||
|
if self._worker is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loaded = self.controller.load_session(item.data(Qt.UserRole))
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
return
|
||||||
|
self._messages = list(loaded.messages)
|
||||||
|
self._draft_user = self._draft_assistant = ""
|
||||||
|
self._render_chat()
|
||||||
|
self.statusBar().showMessage(f"Sitzung geladen: {loaded.session.title}", 3000)
|
||||||
|
|
||||||
|
def _session_menu(self, position) -> None:
|
||||||
|
item = self.session_list.itemAt(position)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
menu = QMenu(self)
|
||||||
|
rename_action = QAction("Umbenennen", menu)
|
||||||
|
rename_action.triggered.connect(lambda: self._rename_item(item))
|
||||||
|
menu.addAction(rename_action)
|
||||||
|
menu.exec(self.session_list.mapToGlobal(position))
|
||||||
|
|
||||||
|
def _rename_item(self, item: QListWidgetItem) -> None:
|
||||||
|
if item.data(Qt.UserRole) != self.controller.active_session_id:
|
||||||
|
self._load_item(item)
|
||||||
|
from PySide6.QtWidgets import QInputDialog
|
||||||
|
|
||||||
|
title, accepted = QInputDialog.getText(self, "Sitzung umbenennen", "Neuer Titel:")
|
||||||
|
if not accepted:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.controller.rename_active_session(title)
|
||||||
|
self._refresh_sessions()
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
|
||||||
|
def _send(self) -> None:
|
||||||
|
text = self.message_edit.toPlainText().strip()
|
||||||
|
if not text or self._worker is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
decision = self.controller.approval_decision(text)
|
||||||
|
if decision is not None:
|
||||||
|
choice = self._show_approval(decision)
|
||||||
|
if choice is ApprovalChoice.CANCEL:
|
||||||
|
self.statusBar().showMessage("Anfrage abgebrochen.", 3000)
|
||||||
|
return
|
||||||
|
self.controller.queue_approval(choice)
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
return
|
||||||
|
self.message_edit.clear()
|
||||||
|
self._draft_user = text
|
||||||
|
self._draft_assistant = ""
|
||||||
|
self._render_chat()
|
||||||
|
self._set_generating(True)
|
||||||
|
self._generation_finalized = False
|
||||||
|
thread = QThread(self)
|
||||||
|
worker = StreamWorker(self.controller, text)
|
||||||
|
worker.moveToThread(thread)
|
||||||
|
thread.started.connect(worker.run)
|
||||||
|
worker.event_received.connect(self._handle_stream_event)
|
||||||
|
worker.completed.connect(thread.quit)
|
||||||
|
worker.completed.connect(worker.deleteLater)
|
||||||
|
worker.completed.connect(self._generation_complete)
|
||||||
|
thread.finished.connect(self._thread_finished)
|
||||||
|
thread.finished.connect(thread.deleteLater)
|
||||||
|
self._thread = thread
|
||||||
|
self._worker = worker
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _handle_stream_event(self, event: StreamEvent) -> None:
|
||||||
|
if self._generation_finalized:
|
||||||
|
return
|
||||||
|
if event.kind is StreamEventKind.CHUNK:
|
||||||
|
self._draft_assistant += event.text
|
||||||
|
self._render_chat()
|
||||||
|
if event.state is not None:
|
||||||
|
self._set_state(event.state)
|
||||||
|
if event.kind is StreamEventKind.ERROR:
|
||||||
|
self._show_error(event.text)
|
||||||
|
|
||||||
|
def _generation_complete(self) -> None:
|
||||||
|
if self._generation_finalized:
|
||||||
|
return
|
||||||
|
self._generation_finalized = True
|
||||||
|
failed = self.controller.state in {
|
||||||
|
GenerationState.ABORTED,
|
||||||
|
GenerationState.ERROR,
|
||||||
|
}
|
||||||
|
self._set_generating(False)
|
||||||
|
try:
|
||||||
|
loaded = self.controller.load_session(self.controller.active_session_id or "")
|
||||||
|
self._messages = list(loaded.messages)
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
self._draft_user = self._draft_assistant = ""
|
||||||
|
self._render_chat()
|
||||||
|
self._refresh_sessions()
|
||||||
|
self._refresh_status()
|
||||||
|
if failed:
|
||||||
|
self.statusBar().showMessage("Antwort abgebrochen oder fehlgeschlagen.", 5000)
|
||||||
|
else:
|
||||||
|
self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000)
|
||||||
|
|
||||||
|
def _thread_finished(self) -> None:
|
||||||
|
self._worker = None
|
||||||
|
self._thread = None
|
||||||
|
if self._close_when_finished:
|
||||||
|
self._close_when_finished = False
|
||||||
|
QTimer.singleShot(0, self.close)
|
||||||
|
|
||||||
|
def _stop_generation(self) -> None:
|
||||||
|
if self._worker is None:
|
||||||
|
return
|
||||||
|
self._worker.request_cancel()
|
||||||
|
self.statusBar().showMessage("Abbruch angefordert …")
|
||||||
|
|
||||||
|
def _set_generating(self, active: bool) -> None:
|
||||||
|
self.send_button.setEnabled(not active)
|
||||||
|
self.stop_button.setEnabled(active)
|
||||||
|
self.new_button.setEnabled(not active)
|
||||||
|
self.session_list.setEnabled(not active)
|
||||||
|
self.mode_combo.setEnabled(not active)
|
||||||
|
|
||||||
|
def _mode_changed(self) -> None:
|
||||||
|
try:
|
||||||
|
self.controller.set_mode(self.mode_combo.currentData())
|
||||||
|
self._refresh_status()
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
|
||||||
|
def _show_approval(self, decision: PrivacyDecision) -> ApprovalChoice:
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setWindowTitle("Cloudfreigabe")
|
||||||
|
box.setIcon(QMessageBox.Question)
|
||||||
|
box.setText(
|
||||||
|
"Diese Anfrage darf nur nach deiner Freigabe an Gemini gesendet werden.\n\n"
|
||||||
|
"Es wird ausschließlich der minimierte notwendige Kontext übertragen."
|
||||||
|
)
|
||||||
|
box.setInformativeText(f"Lokale Einstufung: {decision.reason}")
|
||||||
|
allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole)
|
||||||
|
local = box.addButton("Lokal beantworten", QMessageBox.RejectRole)
|
||||||
|
cancel = box.addButton("Abbrechen", QMessageBox.DestructiveRole)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
choice = ApprovalChoice.CANCEL
|
||||||
|
if clicked is allow:
|
||||||
|
choice = ApprovalChoice.ALLOW
|
||||||
|
elif clicked is local:
|
||||||
|
choice = ApprovalChoice.LOCAL
|
||||||
|
elif clicked is cancel:
|
||||||
|
choice = ApprovalChoice.CANCEL
|
||||||
|
if box.clickedButton() is local:
|
||||||
|
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
|
||||||
|
return choice
|
||||||
|
|
||||||
|
def _show_notice(self, text: str) -> None:
|
||||||
|
self.statusBar().showMessage(text, 5000)
|
||||||
|
|
||||||
|
def _show_error(self, text: str) -> None:
|
||||||
|
QMessageBox.critical(self, "Javis-Fehler", text)
|
||||||
|
self.statusBar().showMessage(f"Fehler: {text}", 5000)
|
||||||
|
|
||||||
|
def _refresh_status(self) -> None:
|
||||||
|
try:
|
||||||
|
status = self.controller.status()
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
return
|
||||||
|
self._set_state(status.state)
|
||||||
|
target_mode = "local" if status.mode == "Lokal" else "auto"
|
||||||
|
mode_index = self.mode_combo.findData(target_mode)
|
||||||
|
if mode_index >= 0 and mode_index != self.mode_combo.currentIndex():
|
||||||
|
blocker = QSignalBlocker(self.mode_combo)
|
||||||
|
self.mode_combo.setCurrentIndex(mode_index)
|
||||||
|
del blocker
|
||||||
|
self.status_labels["provider"].setText(status.provider)
|
||||||
|
self.status_labels["ollama"].setText("Ja" if status.ollama_reachable else "Nein")
|
||||||
|
self.status_labels["model"].setText(status.local_model)
|
||||||
|
self.status_labels["privacy"].setText(status.privacy_policy)
|
||||||
|
self.status_labels["cloud"].setText(
|
||||||
|
str(status.cloud_requests_today)
|
||||||
|
if status.cloud_requests_today is not None
|
||||||
|
else "Unbekannt"
|
||||||
|
)
|
||||||
|
self.status_labels["fallback"].setText("Ja" if status.local_fallback else "Nein")
|
||||||
|
self.status_labels["data"].setText(status.data_dir)
|
||||||
|
|
||||||
|
def _set_state(self, state: GenerationState) -> None:
|
||||||
|
self.state_label.setText(state.value)
|
||||||
|
colors = {
|
||||||
|
GenerationState.READY: ("#24342e", "#86d49b"),
|
||||||
|
GenerationState.THINKING: ("#3a3020", "#e1a54f"),
|
||||||
|
GenerationState.ANSWERING: ("#3a3020", "#e1a54f"),
|
||||||
|
GenerationState.ABORTED: ("#3a3020", "#e1a54f"),
|
||||||
|
GenerationState.ERROR: ("#3a2424", "#ed7777"),
|
||||||
|
GenerationState.OFFLINE: ("#3a2424", "#ed7777"),
|
||||||
|
}
|
||||||
|
background, foreground = colors[state]
|
||||||
|
self.state_label.setStyleSheet(
|
||||||
|
f"background: {background}; color: {foreground}; padding: 10px; "
|
||||||
|
"border-radius: 6px; font-weight: 700;"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _animate_state(self) -> None:
|
||||||
|
if self.controller.state not in {
|
||||||
|
GenerationState.THINKING,
|
||||||
|
GenerationState.ANSWERING,
|
||||||
|
}:
|
||||||
|
return
|
||||||
|
self._thinking_dots = (self._thinking_dots + 1) % 4
|
||||||
|
self.state_label.setText(f"{self.controller.state.value}{'.' * self._thinking_dots}")
|
||||||
|
|
||||||
|
def _render_chat(self) -> None:
|
||||||
|
blocks: list[str] = []
|
||||||
|
messages = list(self._messages)
|
||||||
|
if self._draft_user:
|
||||||
|
messages.append(ChatMessage("user", self._draft_user))
|
||||||
|
if self._draft_assistant:
|
||||||
|
messages.append(ChatMessage("assistant", self._draft_assistant))
|
||||||
|
for message in messages:
|
||||||
|
label = "Pascal" if message.role == "user" else "Javis"
|
||||||
|
content = message.content if message.role == "assistant" else escape(message.content)
|
||||||
|
blocks.append(f"### {label}\n\n{content}")
|
||||||
|
self.chat_view.setMarkdown("\n\n---\n\n".join(blocks))
|
||||||
|
scrollbar = self.chat_view.verticalScrollBar()
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
|
||||||
|
def _focus_search(self) -> None:
|
||||||
|
self.search_edit.setFocus()
|
||||||
|
self.search_edit.selectAll()
|
||||||
|
|
||||||
|
def closeEvent(self, event: QCloseEvent) -> None:
|
||||||
|
if self._thread is not None:
|
||||||
|
self._close_when_finished = True
|
||||||
|
self._stop_generation()
|
||||||
|
event.ignore()
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
"Javis beendet die laufende Antwort noch kontrolliert.",
|
||||||
|
3000,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
event.accept()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""UI-facing immutable models without Qt dependencies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationState(StrEnum):
|
||||||
|
OFFLINE = "Offline"
|
||||||
|
READY = "Bereit"
|
||||||
|
THINKING = "Denkt"
|
||||||
|
ANSWERING = "Antwortet"
|
||||||
|
ABORTED = "Abgebrochen"
|
||||||
|
ERROR = "Fehler"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEventKind(StrEnum):
|
||||||
|
STATE = "state"
|
||||||
|
CHUNK = "chunk"
|
||||||
|
NOTICE = "notice"
|
||||||
|
ERROR = "error"
|
||||||
|
FINISHED = "finished"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamEvent:
|
||||||
|
kind: StreamEventKind
|
||||||
|
text: str = ""
|
||||||
|
state: GenerationState | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DesktopStatus:
|
||||||
|
state: GenerationState
|
||||||
|
mode: str
|
||||||
|
provider: str
|
||||||
|
ollama_reachable: bool
|
||||||
|
local_model: str
|
||||||
|
privacy_policy: str
|
||||||
|
cloud_requests_today: int | None
|
||||||
|
local_fallback: bool
|
||||||
|
data_dir: str
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Qt worker objects for non-blocking model generation and approval."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal, Slot
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
|
from javis.security.privacy import PrivacyDecision
|
||||||
|
from javis.ui.chat_controller import DesktopController
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalBridge(QObject):
|
||||||
|
approval_requested = Signal(object)
|
||||||
|
notice_received = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._event: threading.Event | None = None
|
||||||
|
self._result = ApprovalChoice.CANCEL
|
||||||
|
|
||||||
|
def request(self, decision: PrivacyDecision) -> ApprovalChoice:
|
||||||
|
event = threading.Event()
|
||||||
|
with self._lock:
|
||||||
|
self._event = event
|
||||||
|
self._result = ApprovalChoice.CANCEL
|
||||||
|
self.approval_requested.emit(decision)
|
||||||
|
event.wait()
|
||||||
|
with self._lock:
|
||||||
|
result = self._result
|
||||||
|
self._event = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
def resolve(self, choice: ApprovalChoice) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._result = choice
|
||||||
|
event = self._event
|
||||||
|
if event is not None:
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
def cancel_pending(self) -> None:
|
||||||
|
self.resolve(ApprovalChoice.CANCEL)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamWorker(QObject):
|
||||||
|
event_received = Signal(object)
|
||||||
|
completed = Signal()
|
||||||
|
|
||||||
|
def __init__(self, controller: DesktopController, text: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._controller = controller
|
||||||
|
self._text = text
|
||||||
|
self._cancel_event = threading.Event()
|
||||||
|
self._started = False
|
||||||
|
self._completed = False
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def run(self) -> None:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
try:
|
||||||
|
for event in self._controller.stream_message(
|
||||||
|
self._text,
|
||||||
|
self._cancel_event,
|
||||||
|
):
|
||||||
|
self.event_received.emit(event)
|
||||||
|
finally:
|
||||||
|
if not self._completed:
|
||||||
|
self._completed = True
|
||||||
|
self.completed.emit()
|
||||||
|
|
||||||
|
def request_cancel(self) -> None:
|
||||||
|
self._cancel_event.set()
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
+264
-2
@@ -1,11 +1,21 @@
|
|||||||
|
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_parser,
|
||||||
|
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,8 +25,52 @@ 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_parser_recognizes_gui_and_existing_chat(self) -> None:
|
||||||
|
self.assertEqual(build_parser().parse_args(["gui"]).command, "gui")
|
||||||
|
self.assertEqual(build_parser().parse_args(["chat"]).command, "chat")
|
||||||
|
|
||||||
def test_basic_chat_commands(self) -> None:
|
def test_basic_chat_commands(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
||||||
@@ -31,10 +85,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()
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from javis.core.chat_service import ChatService
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
|
from javis.memory.sqlite_store import SQLiteSessionStore
|
||||||
|
from javis.providers.base import ChatMessage, ResponseAbortedError
|
||||||
|
from javis.security.privacy import CloudPolicy, PrivacyDecision
|
||||||
|
from javis.ui.chat_controller import DesktopController, sanitized_error
|
||||||
|
from javis.ui.models import GenerationState, StreamEventKind
|
||||||
|
|
||||||
|
|
||||||
|
class _Service:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sessions = [
|
||||||
|
SimpleNamespace(id="one", title="Eins"),
|
||||||
|
SimpleNamespace(id="two", title="Zwei"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def new_session(self):
|
||||||
|
return self.sessions[0]
|
||||||
|
|
||||||
|
def list_sessions(self):
|
||||||
|
return self.sessions
|
||||||
|
|
||||||
|
def search_sessions(self, text: str):
|
||||||
|
return [session for session in self.sessions if text in session.title]
|
||||||
|
|
||||||
|
def load_session(self, session_id: str):
|
||||||
|
session = next(item for item in self.sessions if item.id == session_id)
|
||||||
|
return SimpleNamespace(
|
||||||
|
session=session,
|
||||||
|
messages=[ChatMessage("user", "Hallo")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str):
|
||||||
|
session = next(item for item in self.sessions if item.id == session_id)
|
||||||
|
session.title = title
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class _Hybrid:
|
||||||
|
mode = "auto"
|
||||||
|
last_route = None
|
||||||
|
queued_approval = None
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def privacy_decision(self, _text: str) -> PrivacyDecision:
|
||||||
|
return PrivacyDecision(CloudPolicy.ASK, "Test")
|
||||||
|
|
||||||
|
def queue_approval(self, choice: ApprovalChoice) -> None:
|
||||||
|
self.queued_approval = choice
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamingProvider:
|
||||||
|
name = "ollama"
|
||||||
|
model = "test"
|
||||||
|
last_route = None
|
||||||
|
mode = "local"
|
||||||
|
|
||||||
|
def stream_chat(self, _messages):
|
||||||
|
yield "Teil "
|
||||||
|
yield "Antwort"
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
|
||||||
|
class _AbortingProvider(_StreamingProvider):
|
||||||
|
def stream_chat(self, _messages):
|
||||||
|
raise ResponseAbortedError("Vom Dialog abgebrochen")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopControllerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.runtime = SimpleNamespace(
|
||||||
|
service=_Service(),
|
||||||
|
hybrid_provider=_Hybrid(),
|
||||||
|
)
|
||||||
|
self.controller = DesktopController(self.runtime)
|
||||||
|
|
||||||
|
def test_controller_manages_sessions_without_qt_window(self) -> None:
|
||||||
|
created = self.controller.new_session()
|
||||||
|
listed = self.controller.list_sessions()
|
||||||
|
searched = self.controller.list_sessions("Zwei")
|
||||||
|
loaded = self.controller.load_session("two")
|
||||||
|
renamed = self.controller.rename_active_session("Neu")
|
||||||
|
|
||||||
|
self.assertEqual(created.id, "one")
|
||||||
|
self.assertEqual(len(listed), 2)
|
||||||
|
self.assertEqual([session.id for session in searched], ["two"])
|
||||||
|
self.assertEqual(loaded.messages[0].content, "Hallo")
|
||||||
|
self.assertEqual(renamed.title, "Neu")
|
||||||
|
|
||||||
|
def test_controller_preflights_and_queues_gui_approval(self) -> None:
|
||||||
|
decision = self.controller.approval_decision("Private Frage")
|
||||||
|
self.controller.queue_approval(ApprovalChoice.ALLOW)
|
||||||
|
|
||||||
|
self.assertIsNotNone(decision)
|
||||||
|
self.assertIs(decision.policy, CloudPolicy.ASK)
|
||||||
|
self.assertIs(
|
||||||
|
self.runtime.hybrid_provider.queued_approval,
|
||||||
|
ApprovalChoice.ALLOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error_sanitizer_masks_key_patterns(self) -> None:
|
||||||
|
secret = "AIza" + "x" * 25
|
||||||
|
result = sanitized_error(RuntimeError(f"Fehler {secret}"))
|
||||||
|
|
||||||
|
self.assertNotIn(secret, result)
|
||||||
|
self.assertIn("MASKIERT", result)
|
||||||
|
|
||||||
|
def test_streaming_events_are_forwarded_and_completed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
provider = _StreamingProvider()
|
||||||
|
service = ChatService(
|
||||||
|
SQLiteSessionStore(Path(directory) / "sessions.sqlite3"),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
controller = DesktopController(
|
||||||
|
SimpleNamespace(service=service, hybrid_provider=provider)
|
||||||
|
)
|
||||||
|
|
||||||
|
events = list(controller.stream_message("Hallo", threading.Event()))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[event.text for event in events if event.kind is StreamEventKind.CHUNK],
|
||||||
|
["Teil ", "Antwort"],
|
||||||
|
)
|
||||||
|
self.assertEqual(events[-1].kind, StreamEventKind.FINISHED)
|
||||||
|
session = service.list_sessions()[0]
|
||||||
|
self.assertEqual(service.load_session(session.id).session.message_count, 2)
|
||||||
|
|
||||||
|
def test_cancel_drops_partial_exchange(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
provider = _StreamingProvider()
|
||||||
|
service = ChatService(
|
||||||
|
SQLiteSessionStore(Path(directory) / "sessions.sqlite3"),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
controller = DesktopController(
|
||||||
|
SimpleNamespace(service=service, hybrid_provider=provider)
|
||||||
|
)
|
||||||
|
cancel = threading.Event()
|
||||||
|
cancel.set()
|
||||||
|
|
||||||
|
events = list(controller.stream_message("Hallo", cancel))
|
||||||
|
|
||||||
|
self.assertEqual(events[-1].state, GenerationState.ABORTED)
|
||||||
|
session = service.list_sessions()[0]
|
||||||
|
self.assertEqual(service.load_session(session.id).messages, [])
|
||||||
|
|
||||||
|
def test_provider_abort_is_reported_as_aborted_not_error(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
provider = _AbortingProvider()
|
||||||
|
service = ChatService(
|
||||||
|
SQLiteSessionStore(Path(directory) / "sessions.sqlite3"),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
controller = DesktopController(
|
||||||
|
SimpleNamespace(service=service, hybrid_provider=provider)
|
||||||
|
)
|
||||||
|
|
||||||
|
events = list(controller.stream_message("Hallo", threading.Event()))
|
||||||
|
|
||||||
|
self.assertEqual(events[-1].state, GenerationState.ABORTED)
|
||||||
|
self.assertNotIn(StreamEventKind.ERROR, [event.kind for event in events])
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
|
from javis.providers.base import ChatMessage
|
||||||
|
from javis.security.privacy import CloudPolicy, PrivacyDecision
|
||||||
|
from javis.ui.main_window import MainWindow
|
||||||
|
from javis.ui.models import DesktopStatus, GenerationState, StreamEvent, StreamEventKind
|
||||||
|
from javis.ui.workers import ApprovalBridge, StreamWorker
|
||||||
|
|
||||||
|
|
||||||
|
class _Controller:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.active_session_id = None
|
||||||
|
self.state = GenerationState.READY
|
||||||
|
self.session = SimpleNamespace(
|
||||||
|
id="session-one",
|
||||||
|
title="Desktop-Test",
|
||||||
|
last_provider="ollama",
|
||||||
|
message_count=2,
|
||||||
|
updated_at="2026-07-30T20:00:00+00:00",
|
||||||
|
)
|
||||||
|
self.messages = [
|
||||||
|
ChatMessage("user", "Hallo"),
|
||||||
|
ChatMessage("assistant", "Guten Tag"),
|
||||||
|
]
|
||||||
|
self.approval: PrivacyDecision | None = None
|
||||||
|
self.queued_approval: ApprovalChoice | None = None
|
||||||
|
self.started = threading.Event()
|
||||||
|
self.release = threading.Event()
|
||||||
|
self.release.set()
|
||||||
|
|
||||||
|
def list_sessions(self, search: str = ""):
|
||||||
|
return [self.session] if not search or search in self.session.title else []
|
||||||
|
|
||||||
|
def load_session(self, session_id: str):
|
||||||
|
self.active_session_id = session_id
|
||||||
|
return SimpleNamespace(
|
||||||
|
session=self.session,
|
||||||
|
messages=list(self.messages),
|
||||||
|
)
|
||||||
|
|
||||||
|
def new_session(self):
|
||||||
|
self.active_session_id = self.session.id
|
||||||
|
return self.session
|
||||||
|
|
||||||
|
def rename_active_session(self, title: str):
|
||||||
|
self.session.title = title
|
||||||
|
return self.session
|
||||||
|
|
||||||
|
def set_mode(self, _mode: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def approval_decision(self, _text: str) -> PrivacyDecision | None:
|
||||||
|
return self.approval
|
||||||
|
|
||||||
|
def queue_approval(self, choice: ApprovalChoice) -> None:
|
||||||
|
self.queued_approval = choice
|
||||||
|
|
||||||
|
def stream_message(self, text: str, cancel_event: threading.Event):
|
||||||
|
self.started.set()
|
||||||
|
self.state = GenerationState.THINKING
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
while not self.release.wait(0.005):
|
||||||
|
if cancel_event.is_set():
|
||||||
|
self.state = GenerationState.ABORTED
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
return
|
||||||
|
if cancel_event.is_set():
|
||||||
|
self.state = GenerationState.ABORTED
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
return
|
||||||
|
self.state = GenerationState.ANSWERING
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
yield StreamEvent(StreamEventKind.CHUNK, text="Testantwort")
|
||||||
|
self.messages.extend(
|
||||||
|
[
|
||||||
|
ChatMessage("user", text),
|
||||||
|
ChatMessage("assistant", "Testantwort"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.message_count = len(self.messages)
|
||||||
|
self.state = GenerationState.READY
|
||||||
|
yield StreamEvent(StreamEventKind.FINISHED, state=self.state)
|
||||||
|
|
||||||
|
def status(self) -> DesktopStatus:
|
||||||
|
return DesktopStatus(
|
||||||
|
state=self.state,
|
||||||
|
mode="Lokal",
|
||||||
|
provider="ollama",
|
||||||
|
ollama_reachable=True,
|
||||||
|
local_model="test-model",
|
||||||
|
privacy_policy="never",
|
||||||
|
cloud_requests_today=0,
|
||||||
|
local_fallback=False,
|
||||||
|
data_dir="D:\\Testdaten",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _MessageBox:
|
||||||
|
Question = object()
|
||||||
|
AcceptRole = object()
|
||||||
|
RejectRole = object()
|
||||||
|
DestructiveRole = object()
|
||||||
|
selected_label = ""
|
||||||
|
|
||||||
|
def __init__(self, _parent) -> None:
|
||||||
|
self.buttons: dict[str, object] = {}
|
||||||
|
|
||||||
|
def setWindowTitle(self, _text: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def setIcon(self, _icon: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def setText(self, _text: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def setInformativeText(self, _text: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def addButton(self, label: str, _role: object) -> object:
|
||||||
|
button = object()
|
||||||
|
self.buttons[label] = button
|
||||||
|
return button
|
||||||
|
|
||||||
|
def exec(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def clickedButton(self) -> object | None:
|
||||||
|
return self.buttons.get(type(self).selected_label)
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopGuiTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.application = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.bridge = ApprovalBridge()
|
||||||
|
self.controller = _Controller()
|
||||||
|
self.window = MainWindow(self.controller, self.bridge)
|
||||||
|
self.window.show()
|
||||||
|
self.application.processEvents()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.controller.release.set()
|
||||||
|
self.window._stop_generation()
|
||||||
|
self._process_until(lambda: self.window._thread is None)
|
||||||
|
self.window.close()
|
||||||
|
self.application.processEvents()
|
||||||
|
|
||||||
|
def _process_until(self, condition, timeout: float = 2.0) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while not condition() and time.monotonic() < deadline:
|
||||||
|
self.application.processEvents()
|
||||||
|
time.sleep(0.005)
|
||||||
|
self.application.processEvents()
|
||||||
|
self.assertTrue(condition())
|
||||||
|
|
||||||
|
def test_offscreen_window_loads_session_and_core_widgets(self) -> None:
|
||||||
|
self.assertEqual(self.window.windowTitle(), "Javis Desktop")
|
||||||
|
self.assertEqual(self.window.session_list.count(), 1)
|
||||||
|
self.assertIn("Hallo", self.window.chat_view.toPlainText())
|
||||||
|
self.assertTrue(self.window.send_button.isEnabled())
|
||||||
|
self.assertFalse(self.window.stop_button.isEnabled())
|
||||||
|
self.assertEqual(self.window.status_labels["provider"].text(), "ollama")
|
||||||
|
|
||||||
|
def test_approval_dialog_maps_all_three_choices(self) -> None:
|
||||||
|
decision = PrivacyDecision(CloudPolicy.ASK, "persönliche Information")
|
||||||
|
self.bridge.resolve = Mock()
|
||||||
|
expected = {
|
||||||
|
"Einmal erlauben": ApprovalChoice.ALLOW,
|
||||||
|
"Lokal beantworten": ApprovalChoice.LOCAL,
|
||||||
|
"Abbrechen": ApprovalChoice.CANCEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("javis.ui.main_window.QMessageBox", _MessageBox):
|
||||||
|
for label, choice in expected.items():
|
||||||
|
with self.subTest(label=label):
|
||||||
|
_MessageBox.selected_label = label
|
||||||
|
self.assertIs(self.window._show_approval(decision), choice)
|
||||||
|
|
||||||
|
def test_local_completion_and_second_question_keep_gui_reusable(self) -> None:
|
||||||
|
for text in ("Erste Frage", "Zweite Frage"):
|
||||||
|
self.window.message_edit.setPlainText(text)
|
||||||
|
self.window._send()
|
||||||
|
self._process_until(lambda: self.window._thread is None)
|
||||||
|
|
||||||
|
self.assertEqual(self.controller.session.message_count, 6)
|
||||||
|
self.assertIn("Zweite Frage", self.window.chat_view.toPlainText())
|
||||||
|
self.assertTrue(self.window.send_button.isEnabled())
|
||||||
|
|
||||||
|
def test_approval_is_decided_before_worker_starts(self) -> None:
|
||||||
|
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
|
||||||
|
self.window._show_approval = Mock(return_value=ApprovalChoice.ALLOW)
|
||||||
|
|
||||||
|
self.window.message_edit.setPlainText("Private Testfrage")
|
||||||
|
self.window._send()
|
||||||
|
|
||||||
|
self.assertIs(self.controller.queued_approval, ApprovalChoice.ALLOW)
|
||||||
|
self.window._show_approval.assert_called_once()
|
||||||
|
self._process_until(lambda: self.window._thread is None)
|
||||||
|
|
||||||
|
def test_local_approval_choice_is_queued_before_worker_starts(self) -> None:
|
||||||
|
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
|
||||||
|
self.window._show_approval = Mock(return_value=ApprovalChoice.LOCAL)
|
||||||
|
|
||||||
|
self.window.message_edit.setPlainText("Lokal beantworten")
|
||||||
|
self.window._send()
|
||||||
|
|
||||||
|
self.assertIs(self.controller.queued_approval, ApprovalChoice.LOCAL)
|
||||||
|
self._process_until(lambda: self.window._thread is None)
|
||||||
|
|
||||||
|
def test_stop_during_streaming_discards_partial_exchange(self) -> None:
|
||||||
|
self.controller.release.clear()
|
||||||
|
before = list(self.controller.messages)
|
||||||
|
self.window.message_edit.setPlainText("Nicht speichern")
|
||||||
|
self.window._send()
|
||||||
|
self._process_until(self.controller.started.is_set)
|
||||||
|
|
||||||
|
self.window._stop_generation()
|
||||||
|
self._process_until(lambda: self.window._thread is None)
|
||||||
|
|
||||||
|
self.assertEqual(self.controller.messages, before)
|
||||||
|
self.assertEqual(self.controller.state, GenerationState.ABORTED)
|
||||||
|
|
||||||
|
def test_stop_immediately_after_start_is_safe(self) -> None:
|
||||||
|
self.controller.release.clear()
|
||||||
|
self.window.message_edit.setPlainText("Sofort stoppen")
|
||||||
|
self.window._send()
|
||||||
|
self.window._stop_generation()
|
||||||
|
|
||||||
|
self._process_until(lambda: self.window._thread is None)
|
||||||
|
self.assertEqual(self.controller.session.message_count, 2)
|
||||||
|
|
||||||
|
def test_completion_is_idempotent_and_retains_qt_references(self) -> None:
|
||||||
|
worker = object()
|
||||||
|
thread = object()
|
||||||
|
self.window._worker = worker
|
||||||
|
self.window._thread = thread
|
||||||
|
self.window._generation_finalized = False
|
||||||
|
|
||||||
|
self.window._generation_complete()
|
||||||
|
self.window._generation_complete()
|
||||||
|
|
||||||
|
self.assertIs(self.window._worker, worker)
|
||||||
|
self.assertIs(self.window._thread, thread)
|
||||||
|
self.window._thread_finished()
|
||||||
|
self.assertIsNone(self.window._worker)
|
||||||
|
self.assertIsNone(self.window._thread)
|
||||||
|
|
||||||
|
def test_worker_emits_completion_only_once(self) -> None:
|
||||||
|
worker = StreamWorker(self.controller, "Nur einmal")
|
||||||
|
completions: list[bool] = []
|
||||||
|
worker.completed.connect(lambda: completions.append(True))
|
||||||
|
|
||||||
|
worker.run()
|
||||||
|
worker.run()
|
||||||
|
|
||||||
|
self.assertEqual(completions, [True])
|
||||||
|
|
||||||
|
def test_close_during_generation_cancels_then_closes(self) -> None:
|
||||||
|
self.controller.release.clear()
|
||||||
|
self.window.message_edit.setPlainText("Beim Schließen stoppen")
|
||||||
|
self.window._send()
|
||||||
|
self._process_until(self.controller.started.is_set)
|
||||||
|
|
||||||
|
self.window.close()
|
||||||
|
self.assertTrue(self.window._close_when_finished)
|
||||||
|
self._process_until(lambda: self.window._thread is None)
|
||||||
|
self._process_until(lambda: not self.window.isVisible())
|
||||||
@@ -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()
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice, 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_queued_gui_approval_is_one_shot_and_uses_fake_cloud(self) -> None:
|
||||||
|
router = self._router()
|
||||||
|
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
|
||||||
|
router.queue_approval(ApprovalChoice.ALLOW)
|
||||||
|
|
||||||
|
chunks = list(router.stream_chat(question))
|
||||||
|
second_answer = router.chat(question)
|
||||||
|
|
||||||
|
self.assertEqual(chunks, ["Cloud"])
|
||||||
|
self.assertEqual(second_answer, "Lokal")
|
||||||
|
self.assertEqual(len(self.approvals), 1)
|
||||||
|
|
||||||
|
def test_ask_supports_allow_local_and_cancel_choices(self) -> None:
|
||||||
|
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
|
||||||
|
|
||||||
|
allowed = self._router(approval_callback=lambda _decision: ApprovalChoice.ALLOW)
|
||||||
|
self.assertEqual(allowed.chat(question), "Cloud")
|
||||||
|
|
||||||
|
local = self._router(approval_callback=lambda _decision: ApprovalChoice.LOCAL)
|
||||||
|
self.assertEqual(local.chat(question), "Lokal")
|
||||||
|
|
||||||
|
cancelled = self._router(approval_callback=lambda _decision: ApprovalChoice.CANCEL)
|
||||||
|
with self.assertRaises(ResponseAbortedError):
|
||||||
|
cancelled.chat(question)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
def test_gui_script_reuses_safe_starter_without_system_changes(self) -> None:
|
||||||
|
repository_root = Path(__file__).resolve().parents[2]
|
||||||
|
script = (repository_root / "scripts" / "start-javis-gui.ps1").read_text(encoding="utf-8")
|
||||||
|
normalized = script.lower()
|
||||||
|
|
||||||
|
self.assertIn("start-javis.ps1", normalized)
|
||||||
|
self.assertIn("javis_start_interface", normalized)
|
||||||
|
self.assertIn("'gui'", 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()
|
||||||
@@ -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()
|
||||||
@@ -1,21 +1,765 @@
|
|||||||
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 = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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" },
|
||||||
|
{ name = "pyside6" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "google-genai", specifier = ">=2.6,<3" },
|
||||||
|
{ name = "keyring", specifier = ">=25.7,<26" },
|
||||||
|
{ name = "pyside6", specifier = ">=6.8,<7" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "ruff", specifier = ">=0.12,<1" }]
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=8,<9" },
|
||||||
|
{ 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 = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6"
|
||||||
|
version = "6.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyside6-addons" },
|
||||||
|
{ name = "pyside6-essentials" },
|
||||||
|
{ name = "shiboken6" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-addons"
|
||||||
|
version = "6.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyside6-essentials" },
|
||||||
|
{ name = "shiboken6" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-essentials"
|
||||||
|
version = "6.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "shiboken6" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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"
|
||||||
@@ -41,3 +785,184 @@ 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 = "shiboken6"
|
||||||
|
version = "6.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user