aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/ui.py57
1 files changed, 57 insertions, 0 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py
index 4dd758b..189d59a 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -459,6 +459,9 @@ class ChatWindow(QMainWindow):
self.assistant_buffer = ""
self.reasoning_buffer = ""
self.searches: list[dict] = []
+ self.turn_prompt_tokens: int | None = None
+ self.turn_completion_tokens: int | None = None
+ self._conversation_cost = 0.0
# Every rendered bubble, so a reasoning toggle can redraw the
# transcript without refetching anything.
self.bubbles: list[dict] = []
@@ -539,6 +542,9 @@ class ChatWindow(QMainWindow):
self.meter = ContextMeter()
top.addWidget(self.meter)
+ self.cost = CostLabel()
+ top.addWidget(self.cost)
+
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search history… (Ctrl+F)")
self.search_box.setClearButtonEnabled(True)
@@ -779,6 +785,7 @@ class ChatWindow(QMainWindow):
self.show_status("; ".join(all_problems), error=True)
else:
self.hide_status()
+ self.update_cost()
def current_model(self) -> str:
return self.model_box.currentData() or self.model_box.currentText()
@@ -941,10 +948,12 @@ class ChatWindow(QMainWindow):
self.history_list.clearSelection()
self.hide_status()
self.select_prompt(self.cfg.default_prompt)
+ self._conversation_cost = 0.0
self.update_meter()
# Starting a conversation implies wanting to type in it, and this
# also takes focus back out of the search box.
self.input.setFocus()
+ self.update_cost()
# -- attachments ------------------------------------------------------
@@ -1139,6 +1148,7 @@ class ChatWindow(QMainWindow):
"""
if not hasattr(self, "meter"):
return # still building the window
+ self.update_cost()
info = self.current_info()
limit = info.ctx_size or 0
@@ -1167,6 +1177,36 @@ class ChatWindow(QMainWindow):
used = backend.estimate_tokens(messages, self.cfg.chars_per_token)
self.meter.set_usage(used, limit, exact=False)
+ def update_cost(self) -> None:
+ """Refresh the money readout from stored counts plus the draft."""
+ if not hasattr(self, "cost"):
+ return # still building the window
+ model_id = self.current_model()
+ billable = models_mod.is_billable(model_id, self.cfg.providers)
+ priced = models_mod.is_priced(model_id, self.cfg.providers, self.store)
+ if not billable or not priced:
+ self.cost.set_cost(0.0, 0.0, billable, priced)
+ return
+
+ # Reopening a conversation resends its whole history, so the
+ # projection has to price everything that would go out, not just
+ # what was typed. That is what makes an expensive turn visible
+ # before it is paid rather than after.
+ pending = 0
+ if self.session_id is not None or self.input.toPlainText():
+ pending = backend.estimate_tokens(
+ self._chat_context(
+ backend.build_user_content(
+ self.input.toPlainText(), self.attachments
+ )
+ ),
+ self.cfg.chars_per_token,
+ )
+ projected = models_mod.projected_cost(
+ pending, model_id, self.cfg.providers, self.store
+ )
+ self.cost.set_cost(self._conversation_cost, projected, billable, priced)
+
def _search_config(self) -> SearchConfig:
return SearchConfig(
enabled=self.cfg.search_enabled,
@@ -1218,7 +1258,10 @@ class ChatWindow(QMainWindow):
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.turn_prompt_tokens = prompt_tokens
+ self.turn_completion_tokens = max(total_tokens - prompt_tokens, 0)
self.meter.set_usage(self.exact_tokens, limit, exact=True)
+ self.update_cost()
@Slot(str)
def _on_reasoning(self, piece: str) -> None:
@@ -1254,12 +1297,20 @@ class ChatWindow(QMainWindow):
self.assistant_buffer,
self.reasoning_buffer,
self._searches_json(),
+ prompt_tokens=self.turn_prompt_tokens,
+ completion_tokens=self.turn_completion_tokens,
+ model=self.current_model(),
+ )
+ rows = self.history.messages(self.session_id)
+ self._conversation_cost = models_mod.conversation_cost(
+ rows, self.cfg.providers, self.store
)
titled = self.assistant_message_id is not None and self._should_title()
self._teardown_stream()
if titled:
self._start_titling()
self.refresh_history()
+ self.update_cost()
def _should_title(self) -> bool:
session = self.history.get_session(self.session_id)
@@ -1329,6 +1380,8 @@ class ChatWindow(QMainWindow):
self.thread = None
self.worker = None
self.assistant_message_id = None
+ self.turn_prompt_tokens = None
+ self.turn_completion_tokens = None
self.send_button.setEnabled(True)
self.stop_button.hide()
@@ -1521,6 +1574,9 @@ class ChatWindow(QMainWindow):
"searches": _searches(_column(row, "searches")),
}
)
+ self._conversation_cost = models_mod.conversation_cost(
+ self.history.messages(session_id), self.cfg.providers, self.store
+ )
self._render()
self._scroll_to_end()
@@ -1534,6 +1590,7 @@ class ChatWindow(QMainWindow):
)
else:
self.hide_status()
+ self.update_cost()
# -- status line ------------------------------------------------------