aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-10 18:41:03 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 18:41:03 +0200
commit9d03d35eadbc1c5bdd8a1cde8397e7ed61791a33 (patch)
tree0b7c0ff3b25a18a069d50bb548bd1b0120bf0f4b
parenta7dd41385baa8f3ac34deda827f0344f82fb713e (diff)
downloadllamachat-9d03d35eadbc1c5bdd8a1cde8397e7ed61791a33.tar.gz
llamachat-9d03d35eadbc1c5bdd8a1cde8397e7ed61791a33.zip
feat: list and route models through every configured provider
Metadata now resolves models.ini over provider defaults over presets.ini, so a local model keeps getting its context and vision from the preset while remaining overridable. A model whose vision support is unknown no longer blocks an attachment: unknown is not the same as no, and the API will reject an image if it really cannot take one. Key resolution moves to the worker thread so a pass: key's pinentry never freezes the GUI. KeyResolver gains a lock since StreamWorker and TitleWorker can now overlap. Skipped providers surface in the status bar rather than only on stderr. model_info uses dataclasses.replace to avoid mutating the ModelInfo that resolve() may return from the store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--llamachat/__main__.py8
-rw-r--r--llamachat/config.py5
-rw-r--r--llamachat/providers.py75
-rw-r--r--llamachat/ui.py104
-rwxr-xr-xtest_llamachat.py12
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()