diff options
Diffstat (limited to 'llamachat/config.py')
| -rw-r--r-- | llamachat/config.py | 167 |
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 |
