diff options
| -rw-r--r-- | llamachat/__main__.py | 8 | ||||
| -rw-r--r-- | llamachat/config.py | 5 | ||||
| -rw-r--r-- | llamachat/providers.py | 75 | ||||
| -rw-r--r-- | llamachat/ui.py | 104 | ||||
| -rwxr-xr-x | test_llamachat.py | 12 |
5 files changed, 138 insertions, 66 deletions
diff --git a/llamachat/__main__.py b/llamachat/__main__.py index b8979e4..c051939 100644 --- a/llamachat/__main__.py +++ b/llamachat/__main__.py @@ -158,8 +158,9 @@ def _run_daemon(cfg) -> int: from PySide6.QtGui import QAction, QIcon, QPixmap, QPainter, QColor, QFont from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon - from .backend import Client + from .backend import MultiClient from .db import History + from . import models from .ui import ChatWindow config.write_default() @@ -170,9 +171,10 @@ def _run_daemon(cfg) -> int: app.setQuitOnLastWindowClosed(False) history = History(cfg.db_path) - client = Client(cfg.base_url, cfg.request_timeout) + client = MultiClient(cfg.providers, cfg.request_timeout) presets = config.parse_presets(cfg.presets_path) - window = ChatWindow(cfg, history, client, presets) + store = models.ModelStore(cfg.models_path) + window = ChatWindow(cfg, history, client, presets, store) def toggle() -> bool: if window.isVisible(): diff --git a/llamachat/config.py b/llamachat/config.py index 8fa4933..bc7bd24 100644 --- a/llamachat/config.py +++ b/llamachat/config.py @@ -91,6 +91,7 @@ class Config: max_searches: int providers: dict[str, providers_mod.Provider] models_path: Path + provider_warnings: list[str] def _runtime_dir() -> Path: @@ -133,7 +134,8 @@ def load(path: Path = CONFIG_PATH) -> Config: # Dropping base_url from DEFAULTS, or letting a caller reach parse() # without one, would turn every such config into an app with no local # provider at all. Keep the key. - provider_table = providers_mod.parse(values) + provider_warnings: list[str] = [] + provider_table = providers_mod.parse(values, warnings=provider_warnings) return Config( base_url=str(values["base_url"]).rstrip("/"), @@ -159,6 +161,7 @@ def load(path: Path = CONFIG_PATH) -> Config: # Cloud model metadata, cached beside state.ini for the same reason: # it is machine-written, not user-editable config. models_path=path.parent / "models.ini", + provider_warnings=provider_warnings, ) diff --git a/llamachat/providers.py b/llamachat/providers.py index 4c3e774..ce2d01d 100644 --- a/llamachat/providers.py +++ b/llamachat/providers.py @@ -17,6 +17,7 @@ import math import os import subprocess import sys +import threading from dataclasses import dataclass, field # The local llama.cpp router. Its models are shown and stored without a @@ -81,25 +82,35 @@ def _not_a_table(name: str, entry) -> str: return f"a provider must be a [providers.{name}] table" -def _skipped(name: str, reason: str) -> None: +def _skipped(name: str, reason: str, warnings: list[str] | None = None) -> None: """Say that a provider was dropped, so the loss is not silent. A skipped provider is invisible: its models are simply absent from the picker, which looks identical to the provider being down. One stderr line gives a user running from a terminal something to act on. + When `warnings` is provided, the message is also collected there so the + UI can surface it in the status bar — a desktop launch has no terminal, + so stderr alone is invisible to the user. + ponytail: stderr only, so a launch from a .desktop file still says nothing. Surfacing this in the window is Task 12's job; there is no window to put it in yet. """ - print(f"llamachat: ignoring provider {name!r}: {reason}", file=sys.stderr) + msg = f"ignoring provider {name!r}: {reason}" + print(f"llamachat: {msg}", file=sys.stderr) + if warnings is not None: + warnings.append(msg) -def parse(values: dict) -> dict[str, Provider]: +def parse(values: dict, warnings: list[str] | None = None) -> dict[str, Provider]: """Build the provider table from already-loaded config values. Takes the raw dict rather than a path so config.py owns file reading and this stays testable without touching disk. + + When `warnings` is provided, skipped-provider messages are collected + there so the UI can surface them in the status bar. """ raw = values.get("providers") # `providers = "oops"` is valid TOML, and coercing it raises: ValueError @@ -128,7 +139,7 @@ def parse(values: dict) -> dict[str, Provider]: # something matters most, not least. The app comes up working, local # answers, and an api_key or ctx_size they set is simply gone, with a # healthy-looking window as the only feedback. - _skipped(LOCAL, _not_a_table(LOCAL, raw_local)) + _skipped(LOCAL, _not_a_table(LOCAL, raw_local), warnings) local = raw_local if isinstance(raw_local, dict) else {} if bare and not local.get("base_url"): table[LOCAL] = {**local, "base_url": bare} @@ -140,20 +151,20 @@ def parse(values: dict) -> dict[str, Provider]: # Most likely [[providers.x]] written for [providers.x], which # TOML reads as a list of tables. Skipping keeps every other # provider working, including local. - _skipped(name, _not_a_table(name, entry)) + _skipped(name, _not_a_table(name, entry), warnings) continue if not name or ":" in name: # The id scheme splits on the first colon, so a name containing # one builds ids that split back to something else entirely, and # an empty name builds ":model". Both route to local under a # nonsense name, silently. Skipping is the only honest option. - _skipped(name, "a provider name cannot be empty or contain ':'") + _skipped(name, "a provider name cannot be empty or contain ':'", warnings) continue base_url = str(entry.get("base_url") or "").rstrip("/") if not base_url: # ponytail: a provider with no URL is misconfigured, not a # partial one. Skipping beats inventing a default endpoint. - _skipped(name, "it has no base_url") + _skipped(name, "it has no base_url", warnings) continue # filter = "qwen" is an easy TOML slip for filter = ["qwen"], and # iterating the string would turn it into four single-character @@ -242,16 +253,15 @@ class KeyResolver: def __init__(self, runner=_run_pass): self._runner = runner - # Not locked, and that is only safe while every caller resolves on the - # GUI thread, where the Qt event loop serializes them. Moving a - # resolve onto a worker thread, or threading the model fan-out, breaks - # it: two threads can miss the cache together, spawn two `pass` - # processes and raise two pinentry prompts for one token. Whoever does - # that adds a threading.Lock here and holds it across the whole - # resolve() body, not just the dict access, since guarding only the - # dict leaves both threads outside the lock during the subprocess, - # which is the double-prompt window itself. + # Locked: resolve() now runs on worker threads (StreamWorker.run and + # TitleWorker.run can overlap), so the lock serializes the entire + # resolve body, including the subprocess call. Guarding only the dict + # would leave both threads outside the lock during `pass show`, which + # is the double-prompt window: two threads miss the cache together, + # spawn two `pass` processes and raise two pinentry prompts for one + # hardware token. self._cache: dict[tuple[str, str], str] = {} + self._lock = threading.Lock() def resolve(self, provider: Provider) -> str: """The bearer token for this provider, or '' when it needs none.""" @@ -263,22 +273,23 @@ class KeyResolver: # the old key after the user edits the entry to fix it, so the fix # would look like it did nothing until a restart. cache_key = (provider.name, spec) - if cache_key in self._cache: - return self._cache[cache_key] - - if spec.startswith("env:"): - value = os.environ.get(spec[4:], "") - if not value: - raise KeyResolutionError( - f"{provider.name}: environment variable {spec[4:]} is not set" - ) - elif spec.startswith("pass:"): - value = self._from_pass(provider, spec[5:]) - else: - value = spec - - self._cache[cache_key] = value - return value + with self._lock: + if cache_key in self._cache: + return self._cache[cache_key] + + if spec.startswith("env:"): + value = os.environ.get(spec[4:], "") + if not value: + raise KeyResolutionError( + f"{provider.name}: environment variable {spec[4:]} is not set" + ) + elif spec.startswith("pass:"): + value = self._from_pass(provider, spec[5:]) + else: + value = spec + + self._cache[cache_key] = value + return value def _from_pass(self, provider: Provider, entry: str) -> str: try: diff --git a/llamachat/ui.py b/llamachat/ui.py index 6c26ca2..94eb000 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -13,6 +13,7 @@ # GNU General Public License for more details. """The chat window.""" +import dataclasses import datetime import html import json @@ -33,6 +34,7 @@ from PySide6.QtWidgets import ( ) from . import backend, models as models_mod, prompts +from . import providers as providers_mod from .backend import Attachment, BackendError, SearchConfig from .config import GLOBAL_PROMPT @@ -213,9 +215,20 @@ class StreamWorker(QObject): @Slot() def run(self) -> None: + # Resolve the client and wire name on the worker thread, not the + # GUI thread: client_for() resolves the API key, and a pass: key + # runs `pass show` which blocks on a pinentry for as long as the user + # takes to find and touch their hardware token. Resolving in + # _start_stream before moveToThread would freeze the whole window. try: - stream = self.client.stream_chat( - self.model, self.messages, self.search_cfg + client = self.client.client_for(self.model) + wire = self.client.wire_name(self.model) + except providers_mod.KeyResolutionError as exc: + self.failed.emit(str(exc)) + return + try: + stream = client.stream_chat( + wire, self.messages, self.search_cfg ) for kind, piece in stream: if self._stop: @@ -262,9 +275,16 @@ class TitleWorker(QObject): @Slot() def run(self) -> None: + # Resolve on the worker thread for the same reason as StreamWorker: + # a pass: key blocks on a pinentry. Titling is never the user's own + # turn, so a key error skips silently rather than showing a banner. try: - raw = self.client.complete(self.model, self.messages) + client = self.client.client_for(self.model) + wire = self.client.wire_name(self.model) + raw = client.complete(wire, self.messages) title = backend.clean_title(raw) + except providers_mod.KeyResolutionError: + return # silent skip, titling is never the user's turn except Exception: # noqa: BLE001 - a missing title is not an error title = "" if title: @@ -406,12 +426,13 @@ class PromptDialog(QDialog): class ChatWindow(QMainWindow): """Model picker, mode toggle, transcript, input, history panel.""" - def __init__(self, cfg, history, client, presets): + def __init__(self, cfg, history, client, presets, store): super().__init__() self.cfg = cfg self.history = history self.client = client self.presets = presets + self.store = store self.prompts = prompts.PromptStore(cfg.prompts_dir) self.prompts.ensure_default() @@ -717,19 +738,20 @@ class ChatWindow(QMainWindow): # -- model handling --------------------------------------------------- def refresh_models(self) -> None: - """Repopulate the picker from the router, keeping the selection.""" + """Repopulate the picker from every provider, keeping the selection.""" previous = self.model_box.currentText() - try: - available = self.client.models() - except BackendError as exc: - self.show_status(str(exc), error=True) + available, listing_problems = self.client.models() + if not available: + self.show_status( + "; ".join(listing_problems) or "No models available", error=True + ) return self.model_box.blockSignals(True) self.model_box.clear() for name in available: - preset = self.presets.get(name) - label = f"{name} 👁" if preset and preset.vision else name + info = self.model_info(name) + label = f"{name} 👁" if info.vision else name self.model_box.addItem(label, name) self.model_box.blockSignals(False) @@ -740,29 +762,54 @@ class ChatWindow(QMainWindow): index = self.model_box.findText(target) if index >= 0: self.model_box.setCurrentIndex(index) - if available: + # Skipped providers from config parsing and listing failures from + # unreachable providers are both worth saying, even when others worked. + all_problems = list(self.cfg.provider_warnings) + list(listing_problems) + if all_problems: + self.show_status("; ".join(all_problems), error=True) + else: self.hide_status() def current_model(self) -> str: return self.model_box.currentData() or self.model_box.currentText() + def model_info(self, model_id: str): + """Metadata for one model: models.ini, then provider, then presets. + + Local models get their context and vision from presets.ini, which + the first two layers can still override if the user entered values. + Uses dataclasses.replace so the ModelInfo that resolve() returns is + never mutated: when a model has no configured provider, resolve() + returns the stored instance directly, and mutating it would corrupt + the store's in-memory representation. + """ + info = models_mod.resolve(model_id, self.cfg.providers, self.store) + preset = self.presets.get(model_id) + if preset is not None: + updates = {} + if info.ctx_size is None: + updates["ctx_size"] = preset.ctx_size + if info.vision is None: + updates["vision"] = preset.vision + if updates: + info = dataclasses.replace(info, **updates) + return info + + def current_info(self): + return self.model_info(self.current_model()) + def current_preset(self): return self.presets.get(self.current_model()) def char_budget(self) -> int: - preset = self.current_preset() - if preset is None: - return int(4096 * self.cfg.chars_per_token * self.cfg.attach_ctx_fraction) - return preset.char_budget( - self.cfg.attach_ctx_fraction, self.cfg.chars_per_token - ) + ctx = self.current_info().ctx_size or 4096 + return int(ctx * self.cfg.chars_per_token * self.cfg.attach_ctx_fraction) def vision_models(self) -> list[str]: names = [] for i in range(self.model_box.count()): name = self.model_box.itemData(i) - preset = self.presets.get(name) - if preset and preset.vision: + if self.model_info(name).vision: names.append(name) return names @@ -891,8 +938,13 @@ class ChatWindow(QMainWindow): def _ensure_vision_model(self) -> bool: """Offer to switch to a vision preset. True when one is active.""" - preset = self.current_preset() - if preset and preset.vision: + info = self.current_info() + if info.vision: + return True + # Unknown is not the same as "no": a cloud model we know nothing + # about may well accept images, and the API will say so if it does + # not. Only a model known to lack vision gets stopped here. + if info.vision is None: return True candidates = self.vision_models() @@ -1049,8 +1101,8 @@ class ChatWindow(QMainWindow): """ if not hasattr(self, "meter"): return # still building the window - preset = self.current_preset() - limit = preset.ctx_size if preset else 0 + info = self.current_info() + limit = info.ctx_size or 0 draft = self.input.toPlainText() pending = backend.build_user_content(draft, self.attachments) @@ -1124,8 +1176,8 @@ class ChatWindow(QMainWindow): @Slot(int, int) def _on_usage(self, prompt_tokens: int, total_tokens: int) -> None: """Replace the estimate with the counts the server reported.""" - preset = self.current_preset() - limit = preset.ctx_size if preset else 0 + info = self.current_info() + limit = info.ctx_size or 0 # What the next turn starts from is everything sent plus the reply. self.exact_tokens = total_tokens or prompt_tokens self.meter.set_usage(self.exact_tokens, limit, exact=True) diff --git a/test_llamachat.py b/test_llamachat.py index 18b6f0a..98776b8 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -646,6 +646,7 @@ def test_sidebar_toggle(): from llamachat import backend as _backend from llamachat import config as _config + from llamachat import models as _models from llamachat.ui import ChatWindow app = QApplication.instance() or QApplication([]) @@ -660,10 +661,11 @@ def test_sidebar_toggle(): assert cfg.state_path.parent == Path(tmp), cfg.state_path history = db.History(cfg.db_path) - client = _backend.Client(cfg.base_url) + client = _backend.MultiClient(cfg.providers, cfg.request_timeout) presets = _config.parse_presets(cfg.presets_path) + store = _models.ModelStore(cfg.models_path) - window = ChatWindow(cfg, history, client, presets) + window = ChatWindow(cfg, history, client, presets, store) window.resize(1000, 700) # The shortcut must be registered on the window itself. @@ -704,7 +706,7 @@ def test_sidebar_toggle(): # Hidden state and width must come back on the next start. window.sidebar_button.setChecked(False) window._save_layout() - restored = ChatWindow(cfg, history, client, presets) + restored = ChatWindow(cfg, history, client, presets, store) assert not restored.sidebar_button.isChecked() assert restored.sidebar_width == 333, restored.sidebar_width @@ -723,6 +725,7 @@ def test_shortcuts(): from llamachat import backend as _backend from llamachat import config as _config + from llamachat import models as _models from llamachat.ui import ChatWindow app = QApplication.instance() or QApplication([]) @@ -735,8 +738,9 @@ def test_shortcuts(): window = ChatWindow( cfg, history, - _backend.Client(cfg.base_url), + _backend.MultiClient(cfg.providers, cfg.request_timeout), _config.parse_presets(cfg.presets_path), + _models.ModelStore(cfg.models_path), ) window.resize(1000, 700) window.show() |
