From ab6ea485dc91537ed7f94d02252d4d464e99c6bf Mon Sep 17 00:00:00 2001 From: Dystroyer8 Date: Thu, 30 Jul 2026 18:03:40 +0200 Subject: [PATCH] feat: add local zero-cost configuration --- config/README.md | 10 ++- config/javis.example.toml | 35 ++++------ docs/CHATGPT_HANDOFF.md | 10 ++- docs/PROJECT_STATUS.md | 6 +- src/javis/config/local_config.py | 113 +++++++++++++++++++++++++++++++ src/javis/config/settings.py | 32 +++++++-- tests/unit/test_settings.py | 56 +++++++++++++++ 7 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 src/javis/config/local_config.py diff --git a/config/README.md b/config/README.md index 7707c37..415508b 100644 --- a/config/README.md +++ b/config/README.md @@ -1,5 +1,11 @@ # 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. diff --git a/config/javis.example.toml b/config/javis.example.toml index bd88dd8..bc24c17 100644 --- a/config/javis.example.toml +++ b/config/javis.example.toml @@ -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] -active = "ollama" +active = "auto" + +[providers.ollama] model = "qwen3:8b" -ollama_base_url = "http://127.0.0.1:11434" +base_url = "http://127.0.0.1:11434" timeout_seconds = 180 -cloud_fallback = false -[obsidian] +[providers.gemini] enabled = false -vault_path = "" -write_areas = [] - -[security] -secrets_provider = "none" -require_approval_for_writes = true +model = "gemini-3.6-flash" +optional_model = "gemini-3.5-flash-lite" +free_only = true +billing_confirmed_disabled = false +max_requests_per_day = 25 +max_input_chars = 12000 +max_output_tokens = 1024 +timeout_seconds = 30 +max_attempts = 2 +max_context_messages = 6 diff --git a/docs/CHATGPT_HANDOFF.md b/docs/CHATGPT_HANDOFF.md index cbd91c0..5586d91 100644 --- a/docs/CHATGPT_HANDOFF.md +++ b/docs/CHATGPT_HANDOFF.md @@ -86,6 +86,10 @@ Rootserver: 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. ## Aktuelle Architektur @@ -137,6 +141,8 @@ Rootserver: 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. ## Aktuelle Tests @@ -151,7 +157,7 @@ Letzter bestätigter Projektstand: - uv-Lock und `uv sync --dev`: bestanden - Python in `.venv`: 3.12.13 - Ruff in `.venv`: 0.16.0 -- Unit-Tests: 51 bestanden; zusätzlich 13 Router- und 1 Kompatibilitätstest +- Unit-Tests: 55 bestanden; zusätzlich 4 lokale Konfigurationstests - Ruff Lint: bestanden - Ruff Formatprüfung: bestanden @@ -237,7 +243,7 @@ Abnahmestatus: 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` mit standardmäßig abgelehnter `ask`-Freigabe implementieren 3. noch keine echte Cloudanfrage ausführen und noch nicht nach `main` mergen diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index c0ef173..008fe3f 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -53,7 +53,9 @@ Stand: lokaler Textchat auf `main`, 30.07.2026. Aktivierung, Billing-Bestätigung, Tages-/Größenlimit und minimierten Kontext - sichtbares lokales Fallback für fehlenden/ungültigen Key, 429, Netzwerk, Datenschutz und lokale Grenzen; alte Ollama-Sitzungen bleiben ladbar -- 51 Unit-Tests aktuell erfolgreich; Ruff-Format und Lint sauber +- 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 @@ -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. `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. -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. diff --git a/src/javis/config/local_config.py b/src/javis/config/local_config.py new file mode 100644 index 0000000..fb9df20 --- /dev/null +++ b/src/javis/config/local_config.py @@ -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 diff --git a/src/javis/config/settings.py b/src/javis/config/settings.py index 34fde5d..f856f7e 100644 --- a/src/javis/config/settings.py +++ b/src/javis/config/settings.py @@ -8,6 +8,8 @@ from dataclasses import dataclass from pathlib import Path from urllib.parse import urlparse +from javis.config.local_config import LocalConfigError, load_local_defaults + class ConfigurationError(ValueError): """Raised when runtime configuration is unsafe or invalid.""" @@ -88,13 +90,35 @@ class Settings: def database_path(self) -> Path: 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 def from_env(cls, environ: Mapping[str, str] | None = None) -> Settings: - values = os.environ if environ is None else environ - configured_dir = values.get("JAVIS_DATA_DIR") - data_dir = Path(configured_dir).expanduser() if configured_dir else default_data_dir(values) + environment = os.environ if environ is None else environ + configured_dir = environment.get("JAVIS_DATA_DIR") + data_dir = ( + Path(configured_dir).expanduser() if configured_dir else default_data_dir(environment) + ) if not data_dir.is_absolute(): 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() 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 settings = cls( - data_dir=data_dir.resolve(), + data_dir=data_dir, provider=provider, model=model, ollama_base_url=base_url, diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 8366f0b..ab86a4e 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -1,6 +1,12 @@ +import tempfile import unittest 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 @@ -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__": unittest.main()