From a750757d8e0e716cff967c22dde6ebe2461ae07c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 31 Aug 2025 21:44:35 +0530 Subject: [PATCH] More work on OpenRouter config --- src/calibre/ai/open_router/backend.py | 4 + src/calibre/ai/open_router/config.py | 140 +++++++++++++++++++++++--- src/calibre/ebooks/txt/processor.py | 2 +- 3 files changed, 130 insertions(+), 16 deletions(-) diff --git a/src/calibre/ai/open_router/backend.py b/src/calibre/ai/open_router/backend.py index 35e207e28d..a81b990827 100644 --- a/src/calibre/ai/open_router/backend.py +++ b/src/calibre/ai/open_router/backend.py @@ -97,6 +97,10 @@ class Pricing(NamedTuple): input_cache_read=float(x.get('input_cache_read', 0)), input_cache_write=float(x.get('input_cache_write', 0)), ) + @property + def is_free(self) -> bool: + return max(self) == 0 + class Model(NamedTuple): name: str diff --git a/src/calibre/ai/open_router/config.py b/src/calibre/ai/open_router/config.py index fd9d011f5f..eee458dd75 100644 --- a/src/calibre/ai/open_router/config.py +++ b/src/calibre/ai/open_router/config.py @@ -2,18 +2,43 @@ # License: GPLv3 Copyright: 2025, Kovid Goyal from functools import partial -from typing import Any +from typing import TYPE_CHECKING, Any -from qt.core import QAbstractListModel, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSortFilterProxyModel, Qt, QWidget, pyqtSignal +from qt.core import ( + QAbstractItemView, + QAbstractListModel, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListView, + QModelIndex, + QPushButton, + QSize, + QSortFilterProxyModel, + QSplitter, + Qt, + QTextBrowser, + QVBoxLayout, + QWidget, + pyqtSignal, +) +from calibre.ai import AICapabilities +from calibre.ai.open_router import OpenRouterAI from calibre.ai.prefs import pref_for_provider, set_prefs_for_provider from calibre.customize.ui import available_ai_provider_plugins -from calibre.gui2 import error_dialog - -from . import OpenRouterAI +from calibre.ebooks.txt.processor import create_markdown_object +from calibre.gui2 import Application, error_dialog, safe_open_url +from calibre.gui2.widgets2 import Dialog +from calibre.utils.icu import primary_sort_key pref = partial(pref_for_provider, OpenRouterAI.name) +if TYPE_CHECKING: + from calibre.ai.open_router.backend import Model as AIModel + class Model(QWidget): @@ -43,7 +68,7 @@ class Model(QWidget): class ModelsModel(QAbstractListModel): - def __init__(self, parent: QWidget | None = None): + def __init__(self, capabilities, parent: QWidget | None = None): super().__init__(parent) for plugin in available_ai_provider_plugins(): if plugin.name == OpenRouterAI.name: @@ -52,7 +77,8 @@ class ModelsModel(QAbstractListModel): else: raise ValueError('Could not find OpenRouterAI plugin') self.all_models_map = self.backend.get_available_models() - self.all_models = sorted(self.all_models_map.values(), key=lambda m: m.created, reverse=True) + self.all_models = tuple(filter( + lambda m: capabilities & m.capabilities == capabilities, self.all_models_map.values())) def rowCount(self, parent): return len(self.all_models) @@ -71,11 +97,12 @@ class ModelsModel(QAbstractListModel): class ProxyModels(QSortFilterProxyModel): - def __init__(self, parent=None): + def __init__(self, capabilities, parent=None): super().__init__(parent) - self.source_model = ModelsModel(self) + self.source_model = ModelsModel(capabilities, self) self.setSourceModel(self.source_model) self.filters = [] + self.sort_key_funcs = [lambda x: primary_sort_key(x.name)] def filterAcceptsRow(self, source_row: int, source_parent) -> bool: try: @@ -87,11 +114,87 @@ class ProxyModels(QSortFilterProxyModel): return False return True + def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: + a, b = left.data(Qt.ItemDataRole.UserRole), right.data(Qt.ItemDataRole.UserRole) + ka = tuple(f(a) for f in self.sort_key_funcs) + kb = tuple(f(b) for f in self.sort_key_funcs) + return ka < kb -class ChooseModel(QWidget): + def set_filters(self, *filters): + self.filters = filters + self.invalidate() - def __init__(self, parent: QWidget | None = None): + def set_sorts(self, *sorts): + self.sort_key_funcs = sorts + self.invalidate() + + +class ModelDetails(QTextBrowser): + + def __init__(self, parent=None): super().__init__(parent) + self.setOpenLinks(False) + self.anchorClicked.connect(self.open_link) + + def show_model_details(self, m: 'AIModel'): + # we use output token price since there are typically more output than input tokens + if m.pricing.is_free: + price = f"{_('Free')}" + else: + price = '' + if m.pricing.input_token: + price += f'$ {m.pricing.input_token * 1e6:.2g}/M {_("input tokens")} ' + if m.pricing.output_token: + price += f'$ {m.pricing.output_token * 1e6:.2g}/M {_("output tokens")} ' + if m.pricing.image: + price += f'$ {m.pricing.image * 1e3:.2g}/K {_("input images")} ' + md = create_markdown_object(extensions=()) + html = f''' +

{_('Description')}

+
{md.convert(m.description)}
+

{_('Price')}

+

{price}

+ ''' + self.setText(html) + + def sizeHint(self): + return QSize(350, 500) + + def open_link(self, url): + safe_open_url(url) + + +class ChooseModel(Dialog): + + def __init__( + self, model_id: str = '', capabilities: AICapabilities = AICapabilities.text_to_text, parent: QWidget | None = None + ): + self.capabilities = capabilities + super().__init__(title=_('Choose an AI model'), name='open-router-choose-model', parent=parent) + + def sizeHint(self): + return QSize(700, 500) + + def setup_ui(self): + l = QVBoxLayout(self) + self.splitter = s = QSplitter(self) + l.addWidget(s) + self.models = m = QListView(self) + m.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.proxy_model = pm = ProxyModels(self.capabilities, m) + m.setModel(pm) + s.addWidget(m) + self.details = d = ModelDetails(self) + s.addWidget(d) + m.selectionModel().currentChanged.connect(self.current_changed) + + l.addWidget(self.bb) + + def current_changed(self): + idx = self.models.selectionModel().currentIndex() + if idx.isValid(): + model = idx.data(Qt.ItemDataRole.UserRole) + self.details.show_model_details(model) class ConfigWidget(QWidget): @@ -116,12 +219,13 @@ class ConfigWidget(QWidget): self.text_model = tm = Model(parent=self) tm.select_model.connect(self.select_model) l.addRow(_('Model for &text tasks:'), tm) - self.choose_model = cm = ChooseModel(self) - cm.setVisible(False) - l.addRow(cm) def select_model(self, model_id: str, for_text: bool) -> None: - self.model_choice_target = self.sender() + model_choice_target = self.sender() + caps = AICapabilities.text_to_text if for_text else AICapabilities.text_to_image + d = ChooseModel(model_id, caps, self) + if d.exec() == QDialog.DialogCode.Accepted: + model_choice_target.set(d.model_id, d.name) @property def api_key(self) -> str: @@ -141,3 +245,9 @@ class ConfigWidget(QWidget): def save_settings(self): set_prefs_for_provider(OpenRouterAI.name, self.settings) + + +if __name__ == '__main__': + app = Application([]) + d = ChooseModel() + d.exec() diff --git a/src/calibre/ebooks/txt/processor.py b/src/calibre/ebooks/txt/processor.py index 48a17af811..c5ad3b6228 100644 --- a/src/calibre/ebooks/txt/processor.py +++ b/src/calibre/ebooks/txt/processor.py @@ -103,7 +103,7 @@ def convert_basic(txt, title='', epub_split_size_kb=0): DEFAULT_MD_EXTENSIONS = ('footnotes', 'tables', 'toc') -def create_markdown_object(extensions): +def create_markdown_object(extensions=DEFAULT_MD_EXTENSIONS): # Need to load markdown extensions without relying on pkg_resources import importlib