aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/modeldialog.py145
-rwxr-xr-xtest_llamachat.py54
2 files changed, 199 insertions, 0 deletions
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. <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.
+"""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"<b>{model_id}</b>")
+ 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()