feat: add local zero-cost configuration

This commit is contained in:
2026-07-30 18:03:40 +02:00
parent e57186f678
commit ab6ea485dc
7 changed files with 232 additions and 30 deletions
+8 -2
View File
@@ -1,5 +1,11 @@
# Konfiguration # Konfiguration
`javis.example.toml` dokumentiert ausschließlich nicht geheime Einstellungen. Für den lokalen Betrieb wird später eine ignorierte `javis.toml` erzeugt. `javis.example.toml` dokumentiert ausschließlich nicht geheime Einstellungen.
Die lokale Datei liegt standardmäßig unter `JAVIS_DATA_DIR\javis.toml` und damit
außerhalb des Repositorys. `JAVIS_CONFIG_FILE` darf alternativ einen absoluten
Pfad angeben. Umgebungsvariablen überschreiben lokale TOML-Werte.
Secret-Werte gehören nicht in TOML, Code oder Git. Die bevorzugte Zielarchitektur verwendet einen Secret-Provider, beispielsweise Betriebssystem-Keyring, geschützte Umgebungsvariablen oder später Docker Secrets. Falls Pascal XML nutzt, liegt die echte Datei nur lokal, mit restriktiven Rechten und Git-Ausschluss. Das Modell erhält niemals ihren Inhalt. Secret-Werte gehören nicht in TOML, Code oder Git. Der Gemini-Key liegt im
Betriebssystem-Keyring und wird mit `javis secrets` verwaltet. Die TOML-Datei
enthält nur die lokale Bestätigung, dass Pascal das Google-Projekt ohne Billing
betreibt. Javis kann den externen Billingstatus nicht technisch verifizieren.
+15 -20
View File
@@ -1,25 +1,20 @@
# Entwurf für eine spätere TOML-Konfiguration.
# Der erste Chat liest die in .env.example genannten Umgebungsvariablen.
[app]
display_name = "Javis"
language = "de"
[paths]
data_dir = ""
[providers] [providers]
active = "ollama" active = "auto"
[providers.ollama]
model = "qwen3:8b" model = "qwen3:8b"
ollama_base_url = "http://127.0.0.1:11434" base_url = "http://127.0.0.1:11434"
timeout_seconds = 180 timeout_seconds = 180
cloud_fallback = false
[obsidian] [providers.gemini]
enabled = false enabled = false
vault_path = "" model = "gemini-3.6-flash"
write_areas = [] optional_model = "gemini-3.5-flash-lite"
free_only = true
[security] billing_confirmed_disabled = false
secrets_provider = "none" max_requests_per_day = 25
require_approval_for_writes = true max_input_chars = 12000
max_output_tokens = 1024
timeout_seconds = 30
max_attempts = 2
max_context_messages = 6
+8 -2
View File
@@ -86,6 +86,10 @@ Rootserver:
Tages-/Größenlimits und sichtbares Ollama-Fallback vor jedem Cloudaufruf. Tages-/Größenlimits und sichtbares Ollama-Fallback vor jedem Cloudaufruf.
- Ein abgelehnter Schlüssel deaktiviert Gemini für den restlichen CLI-Prozess; - Ein abgelehnter Schlüssel deaktiviert Gemini für den restlichen CLI-Prozess;
alte reine Ollama-Sitzungen bleiben mit dem Hybridprovider ladbar. 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.
## Aktuelle Architektur ## Aktuelle Architektur
@@ -137,6 +141,8 @@ Rootserver:
Test-Doubles gegen Erfolg, Key-, Quota-, Netzwerk- und Antwortfehler geprüft. Test-Doubles gegen Erfolg, Key-, Quota-, Netzwerk- und Antwortfehler geprüft.
- Hybridrouting und Audit mit Test-Doubles integriert: `never` konstruiert keinen - Hybridrouting und Audit mit Test-Doubles integriert: `never` konstruiert keinen
Cloudclient, `ask` verlangt Freigabe, 429/Netzwerk/Keyfehler fallen lokal zurück. 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.
## Aktuelle Tests ## Aktuelle Tests
@@ -151,7 +157,7 @@ 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: 51 bestanden; zusätzlich 13 Router- und 1 Kompatibilitätstest - Unit-Tests: 55 bestanden; zusätzlich 4 lokale Konfigurationstests
- Ruff Lint: bestanden - Ruff Lint: bestanden
- Ruff Formatprüfung: bestanden - Ruff Formatprüfung: bestanden
@@ -237,7 +243,7 @@ Abnahmestatus:
Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket: Auf dem bestehenden Feature-Branch als nächstes einzelnes Arbeitspaket:
1. lokale TOML-Konfiguration und sichere Aktivierungsbestätigung ergänzen 1. sichere Aktivierungsbestätigung als CLI-Einrichtungsschritt ergänzen
2. Hybridrouter in die CLI verdrahten und `/provider`, `/privacy`, `/status` 2. Hybridrouter in die CLI verdrahten und `/provider`, `/privacy`, `/status`
mit standardmäßig abgelehnter `ask`-Freigabe implementieren mit standardmäßig abgelehnter `ask`-Freigabe implementieren
3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen 3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen
+4 -2
View File
@@ -53,7 +53,9 @@ Stand: lokaler Textchat auf `main`, 30.07.2026.
Aktivierung, Billing-Bestätigung, Tages-/Größenlimit und minimierten Kontext Aktivierung, Billing-Bestätigung, Tages-/Größenlimit und minimierten Kontext
- sichtbares lokales Fallback für fehlenden/ungültigen Key, 429, Netzwerk, - sichtbares lokales Fallback für fehlenden/ungültigen Key, 429, Netzwerk,
Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar
- 51 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber - lokale nicht geheime TOML-Konfiguration außerhalb von Git; konservativer
Erzeuger überschreibt keine vorhandene Datei
- 55 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber
## Nicht implementiert ## Nicht implementiert
@@ -79,5 +81,5 @@ Ollama wird bewusst manuell in einem eigenen Terminal gestartet. Antworten werde
noch nicht gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion. noch nicht gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion.
`qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet. `qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet.
Der Hybridprovider ist implementiert, aber noch nicht in der echten CLI verdrahtet. Der Hybridprovider ist implementiert, aber noch nicht in der echten CLI verdrahtet.
Lokale TOML-Aktivierung, Statusbefehle und Startskript fehlen. Es wurde kein echter CLI-Aktivierungsdialog, Statusbefehle und Startskript fehlen. Es wurde kein echter
API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage ausgeführt. API-Key angefordert, angezeigt oder konfiguriert und keine Cloudanfrage ausgeführt.
+113
View File
@@ -0,0 +1,113 @@
"""Non-secret local TOML configuration."""
from __future__ import annotations
import os
import tomllib
from collections.abc import Mapping
from pathlib import Path
class LocalConfigError(ValueError):
"""A local configuration file is malformed or unsafe to replace."""
def _mapping(value: object, section: str) -> Mapping[str, object]:
if value is None:
return {}
if not isinstance(value, dict):
raise LocalConfigError(f"TOML-Abschnitt [{section}] muss eine Tabelle sein.")
return value
def _value(values: Mapping[str, object], name: str) -> str | None:
value = values.get(name)
if value is None:
return None
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (str, int, float)):
return str(value)
raise LocalConfigError(f"TOML-Wert '{name}' hat einen nicht unterstützten Typ.")
def load_local_defaults(path: Path) -> dict[str, str]:
"""Translate the supported non-secret TOML fields to settings inputs."""
if not path.exists():
return {}
try:
with path.open("rb") as handle:
document = tomllib.load(handle)
except (OSError, tomllib.TOMLDecodeError) as exc:
raise LocalConfigError(f"Lokale Konfiguration ist ungültig: {path}") from exc
providers = _mapping(document.get("providers"), "providers")
gemini = _mapping(providers.get("gemini"), "providers.gemini")
ollama = _mapping(providers.get("ollama"), "providers.ollama")
translated: dict[str, str] = {}
fields = (
(providers, "active", "JAVIS_PROVIDER_MODE"),
(ollama, "model", "JAVIS_MODEL"),
(ollama, "base_url", "JAVIS_OLLAMA_URL"),
(ollama, "timeout_seconds", "JAVIS_MODEL_TIMEOUT"),
(gemini, "enabled", "JAVIS_GEMINI_ENABLED"),
(gemini, "model", "JAVIS_GEMINI_MODEL"),
(gemini, "optional_model", "JAVIS_GEMINI_OPTIONAL_MODEL"),
(gemini, "free_only", "JAVIS_FREE_ONLY"),
(
gemini,
"billing_confirmed_disabled",
"JAVIS_GEMINI_BILLING_CONFIRMED_DISABLED",
),
(gemini, "max_requests_per_day", "JAVIS_MAX_CLOUD_REQUESTS_PER_DAY"),
(gemini, "max_input_chars", "JAVIS_MAX_CLOUD_INPUT_CHARS"),
(gemini, "max_output_tokens", "JAVIS_MAX_CLOUD_OUTPUT_TOKENS"),
(gemini, "timeout_seconds", "JAVIS_GEMINI_TIMEOUT"),
(gemini, "max_attempts", "JAVIS_GEMINI_MAX_ATTEMPTS"),
(gemini, "max_context_messages", "JAVIS_MAX_CLOUD_CONTEXT_MESSAGES"),
)
for table, toml_name, setting_name in fields:
value = _value(table, toml_name)
if value is not None:
translated[setting_name] = value
return translated
def write_gemini_activation(path: Path) -> None:
"""Create a conservative non-secret config without replacing existing data."""
if path.exists():
raise LocalConfigError(
f"Lokale Konfiguration existiert bereits und wurde nicht überschrieben: {path}"
)
content = """# Javis local configuration. This file must never contain secrets.
[providers]
active = "auto"
[providers.ollama]
model = "qwen3:8b"
base_url = "http://127.0.0.1:11434"
timeout_seconds = 180
[providers.gemini]
enabled = true
model = "gemini-3.6-flash"
optional_model = "gemini-3.5-flash-lite"
free_only = true
billing_confirmed_disabled = true
max_requests_per_day = 25
max_input_chars = 12000
max_output_tokens = 1024
timeout_seconds = 30
max_attempts = 2
max_context_messages = 6
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = path.with_suffix(path.suffix + ".tmp")
temporary_path.write_text(content, encoding="utf-8")
os.replace(temporary_path, path)
except OSError as exc:
raise LocalConfigError(
f"Lokale Konfiguration konnte nicht geschrieben werden: {path}"
) from exc
+28 -4
View File
@@ -8,6 +8,8 @@ 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."""
@@ -88,13 +90,35 @@ class Settings:
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()
@@ -116,7 +140,7 @@ class Settings:
raise ConfigurationError("JAVIS_GEMINI_TIMEOUT muss eine Zahl sein.") from 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,
+56
View File
@@ -1,6 +1,12 @@
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from javis.config.local_config import (
LocalConfigError,
load_local_defaults,
write_gemini_activation,
)
from javis.config.settings import ConfigurationError, Settings, default_data_dir from javis.config.settings import ConfigurationError, Settings, default_data_dir
@@ -61,6 +67,56 @@ class SettingsTests(unittest.TestCase):
} }
) )
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()