From c8d3298e1bb6a9245449dac1c8727ce3d932cd1d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 10 Aug 2026 18:28:58 +0200 Subject: feat: dialog for per-model context size, vision and prices Field conversion is separated from the widget so the part with the edge cases is testable without a running Qt application. Vision is tri-state through a binary checkbox: an unchecked box with an unknown prefill stays unknown rather than writing False, which would shadow a provider-level vision=True through models.resolve's pick(). Co-Authored-By: Claude Opus 5 --- llamachat/modeldialog.py | 145 +++++++++++++++++++++++++++++++++++++++++++++++ test_llamachat.py | 54 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 llamachat/modeldialog.py diff --git a/llamachat/modeldialog.py b/llamachat/modeldialog.py new file mode 100644 index 0000000..ebebf4a --- /dev/null +++ b/llamachat/modeldialog.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# llamachat - a small native chat client for a local llama.cpp router +# Copyright (C) 2026 Danilo M. +# +# 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. +"""Dialog for entering what presets.ini cannot answer about a model.""" + +import math + +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QLabel, + QLineEdit, + QVBoxLayout, +) + +from .models import ModelInfo + + +def _number(text: str, cast): + """Field text to a number, treating blank and garbage alike as unknown. + + This is the third writer of a price, after providers._number and + models._get, and it needs their non-finite guard for the same reason: + "nan" and "inf" survive float() and would be stored, then rendered as a + "$nan" cost. is_priced() screens them downstream, so the readout stays + honest either way, but a stored nan is a value the dialog would show + back to the user on reopen. + """ + text = (text or "").strip() + if not text: + return None + try: + value = cast(text) + except ValueError: + return None + if cast is float and not math.isfinite(value): + return None + return value + + +def to_info(ctx_text, vision, in_text, out_text, vision_prefill=None): + """Build a ModelInfo from the dialog's raw field values. + + Vision is the one tri-state field, and a binary checkbox cannot hold + three states on its own. The checkbox carries the user's intent (checked + means "yes"), and vision_prefill carries what was known before the + dialog opened: an unchecked box over an unknown prefill stays unknown + rather than writing False, which would shadow a provider-level + vision=True through models.resolve's pick(). An unchecked box over a + known prefill, True or False, is a real "no". + """ + if vision: + resolved_vision = True + elif vision_prefill is None: + resolved_vision = None + else: + resolved_vision = False + return ModelInfo( + ctx_size=_number(ctx_text, int), + vision=resolved_vision, + price_in=_number(in_text, float), + price_out=_number(out_text, float), + ) + + +def to_fields(info: ModelInfo) -> tuple[str, bool, str, str]: + """The inverse, for prefilling. Unknown becomes an empty field.""" + return ( + "" if info.ctx_size is None else str(info.ctx_size), + bool(info.vision), + "" if info.price_in is None else str(info.price_in), + "" if info.price_out is None else str(info.price_out), + ) + + +class ModelDialog(QDialog): + """Context size, vision and prices for one model. + + Prefilled from the provider's defaults, so the common case is checking + the numbers rather than typing them. + """ + + def __init__(self, model_id: str, info: ModelInfo, parent=None): + super().__init__(parent) + self.setWindowTitle("Model settings") + self.model_id = model_id + self._vision_prefill = info.vision + + ctx, vision, price_in, price_out = to_fields(info) + self.ctx = QLineEdit(ctx) + self.ctx.setPlaceholderText("unknown") + self.vision = QCheckBox("Accepts images") + self.vision.setChecked(vision) + self.price_in = QLineEdit(price_in) + self.price_in.setPlaceholderText("unpriced") + self.price_out = QLineEdit(price_out) + self.price_out.setPlaceholderText("unpriced") + + layout = QVBoxLayout(self) + heading = QLabel(f"{model_id}") + heading.setTextInteractionFlags(heading.textInteractionFlags()) + layout.addWidget(heading) + + form = QFormLayout() + form.addRow("Context size (tokens)", self.ctx) + form.addRow("", self.vision) + form.addRow("Input price (per 1M tokens)", self.price_in) + form.addRow("Output price (per 1M tokens)", self.price_out) + layout.addLayout(form) + + note = QLabel( + "Leave prices empty if you do not want a cost estimate.\n" + "Context size drives the meter and the attachment budget." + ) + note.setWordWrap(True) + layout.addWidget(note) + + buttons = QDialogButtonBox( + QDialogButtonBox.Save | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def info(self) -> ModelInfo: + """What the user entered.""" + return to_info( + self.ctx.text(), + self.vision.isChecked(), + self.price_in.text(), + self.price_out.text(), + vision_prefill=self._vision_prefill, + ) \ No newline at end of file diff --git a/test_llamachat.py b/test_llamachat.py index 1217176..b561b66 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -1972,6 +1972,59 @@ def test_multi_client(): print("ok multi-provider client") +def test_model_dialog_values(): + """The dialog's field text converts to ModelInfo, blanks meaning unknown.""" + from llamachat import models, modeldialog + + # Everything filled in. + info = modeldialog.to_info( + ctx_text="32768", vision=True, in_text="1.2", out_text="0.9" + ) + assert info.ctx_size == 32768 + assert info.vision is True + assert info.price_in == 1.2 + assert info.price_out == 0.9 + + # Blank prices are legal and mean unpriced, not free. + blank = modeldialog.to_info( + ctx_text="8192", vision=False, in_text="", out_text=" " + ) + assert blank.ctx_size == 8192 + assert blank.price_in is None + assert blank.price_out is None + + # Garbage reads as unknown rather than crashing the dialog. + junk = modeldialog.to_info( + ctx_text="not a number", vision=False, in_text="free", out_text="" + ) + assert junk.ctx_size is None + assert junk.price_in is None + + # Vision is tri-state through a binary checkbox: an unchecked box with an + # unknown prefill stays unknown, instead of writing False, which would + # shadow a provider-level vision=True through models.resolve's pick(). A + # known prefill (True or False) left unchecked is a real "no". + assert modeldialog.to_info( + ctx_text="", vision=True, in_text="", out_text="" + ).vision is True + assert modeldialog.to_info( + ctx_text="", vision=False, in_text="", out_text="" + ).vision is None + assert modeldialog.to_info( + ctx_text="", vision=False, in_text="", out_text="", vision_prefill=True + ).vision is False + assert modeldialog.to_info( + ctx_text="", vision=False, in_text="", out_text="", vision_prefill=False + ).vision is False + + # Prefill is the inverse: unknown becomes an empty field. + assert modeldialog.to_fields(models.ModelInfo()) == ("", False, "", "") + assert modeldialog.to_fields( + models.ModelInfo(ctx_size=4096, vision=True, price_in=0.5) + ) == ("4096", True, "0.5", "") + print("ok model dialog value conversion") + + class _FakeResponse: """Enough of an http.client response for urlopen's context manager.""" @@ -2734,6 +2787,7 @@ if __name__ == "__main__": test_token_column_migration() test_client_auth_header() test_multi_client() + test_model_dialog_values() test_search_tool_schema() test_search_results_sanitising() test_tool_call_accumulation() -- cgit v1.2.3