feat: add local zero-cost configuration
This commit is contained in:
+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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user