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