aboutsummaryrefslogtreecommitdiffstats
path: root/llamachat/config.py
diff options
context:
space:
mode:
Diffstat (limited to 'llamachat/config.py')
-rw-r--r--llamachat/config.py167
1 files changed, 167 insertions, 0 deletions
diff --git a/llamachat/config.py b/llamachat/config.py
new file mode 100644
index 0000000..fac72d5
--- /dev/null
+++ b/llamachat/config.py
@@ -0,0 +1,167 @@
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# llamachat - a small native chat client for a local llama.cpp router
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+"""Config file loading and llama-server presets.ini parsing."""
+
+import configparser
+import os
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+
+CONFIG_PATH = Path(
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
+) / "llamachat" / "config.toml"
+
+DEFAULTS = {
+ "base_url": "http://localhost:8181",
+ "presets": "/etc/llama-server/presets.ini",
+ "socket": "", # empty -> $XDG_RUNTIME_DIR/llamachat.sock
+ "db": "", # empty -> $XDG_DATA_HOME/llamachat/history.db
+ "default_model": "",
+ "request_timeout": 300,
+ # Fraction of a model's context we allow a single attachment to fill.
+ "attach_ctx_fraction": 0.5,
+ # Rough chars-per-token used to turn a ctx-size into a char budget.
+ "chars_per_token": 3.5,
+}
+
+
+@dataclass
+class Preset:
+ """One [section] of presets.ini, reduced to what the UI needs."""
+ name: str
+ vision: bool
+ ctx_size: int
+
+ def char_budget(self, fraction: float, chars_per_token: float) -> int:
+ return int(self.ctx_size * chars_per_token * fraction)
+
+
+@dataclass
+class Config:
+ base_url: str
+ presets_path: Path
+ socket_path: Path
+ db_path: Path
+ default_model: str
+ request_timeout: int
+ attach_ctx_fraction: float
+ chars_per_token: float
+
+
+def _runtime_dir() -> Path:
+ rd = os.environ.get("XDG_RUNTIME_DIR")
+ if rd:
+ return Path(rd)
+ # ponytail: /tmp fallback only matters on a broken session; no uid-dir
+ # creation dance, XDG_RUNTIME_DIR is set on any real Wayland login.
+ return Path("/tmp")
+
+
+def _data_dir() -> Path:
+ return Path(
+ os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
+ ) / "llamachat"
+
+
+def load(path: Path = CONFIG_PATH) -> Config:
+ """Read config.toml, falling back to DEFAULTS for anything absent."""
+ values = dict(DEFAULTS)
+ if path.exists():
+ with open(path, "rb") as fh:
+ values.update(tomllib.load(fh))
+
+ socket = values["socket"] or (_runtime_dir() / "llamachat.sock")
+ db = values["db"] or (_data_dir() / "history.db")
+
+ return Config(
+ base_url=str(values["base_url"]).rstrip("/"),
+ presets_path=Path(values["presets"]).expanduser(),
+ socket_path=Path(str(socket)).expanduser(),
+ db_path=Path(str(db)).expanduser(),
+ default_model=str(values["default_model"]),
+ request_timeout=int(values["request_timeout"]),
+ attach_ctx_fraction=float(values["attach_ctx_fraction"]),
+ chars_per_token=float(values["chars_per_token"]),
+ )
+
+
+def write_default(path: Path = CONFIG_PATH) -> Path:
+ """Create a commented config.toml if none exists. Returns the path."""
+ if path.exists():
+ return path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ "# llamachat configuration\n"
+ f'base_url = "{DEFAULTS["base_url"]}"\n'
+ f'presets = "{DEFAULTS["presets"]}"\n'
+ '\n'
+ '# Leave empty for XDG defaults:\n'
+ '# socket -> $XDG_RUNTIME_DIR/llamachat.sock\n'
+ '# db -> $XDG_DATA_HOME/llamachat/history.db\n'
+ 'socket = ""\n'
+ 'db = ""\n'
+ '\n'
+ '# Model selected at startup. Empty picks the first available.\n'
+ 'default_model = ""\n'
+ '\n'
+ '# Seconds before a generation request is abandoned.\n'
+ f'request_timeout = {DEFAULTS["request_timeout"]}\n'
+ '\n'
+ '# How much of the model context one attachment may fill, and the\n'
+ '# chars-per-token estimate used to convert ctx-size into characters.\n'
+ f'attach_ctx_fraction = {DEFAULTS["attach_ctx_fraction"]}\n'
+ f'chars_per_token = {DEFAULTS["chars_per_token"]}\n'
+ )
+ return path
+
+
+def parse_presets(path: Path) -> dict[str, Preset]:
+ """Parse llama-server's presets.ini.
+
+ Both ';' and '#' start a comment there, so a commented-out `#mmproj`
+ line correctly reads as "this preset has no vision support".
+ """
+ parser = configparser.ConfigParser(
+ comment_prefixes=(";", "#"),
+ inline_comment_prefixes=(";", "#"),
+ strict=False,
+ )
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError:
+ return {}
+
+ # presets.ini opens with a bare `version = 1` before any section, which
+ # configparser rejects. Give those leading keys a section to live in.
+ try:
+ parser.read_string("[__file__]\n" + text)
+ except configparser.Error:
+ return {}
+
+ presets: dict[str, Preset] = {}
+ for name in parser.sections():
+ if name == "__file__":
+ continue
+ section = parser[name]
+ try:
+ ctx = int(section.get("ctx-size", "4096"))
+ except ValueError:
+ ctx = 4096
+ presets[name] = Preset(
+ name=name,
+ vision=bool(section.get("mmproj", "").strip()),
+ ctx_size=ctx,
+ )
+ return presets