diff --git a/src/calibre/ai/__init__.py b/src/calibre/ai/__init__.py
index a3d9785884..597b93cd10 100644
--- a/src/calibre/ai/__init__.py
+++ b/src/calibre/ai/__init__.py
@@ -33,6 +33,18 @@ class ChatMessage(NamedTuple):
return escape(self.query).replace('\n', '
')
+class ChatResponse(NamedTuple):
+ content: str = ''
+ cost: float = 0
+ currency: str = 'USD'
+ exception: Exception | None = None
+ traceback: str = ''
+
+
+class NoFreeModels(Exception):
+ pass
+
+
class AICapabilities(Flag):
none = auto()
text_to_text = auto()
diff --git a/src/calibre/ai/open_router/backend.py b/src/calibre/ai/open_router/backend.py
index 831d028282..5dda6c6727 100644
--- a/src/calibre/ai/open_router/backend.py
+++ b/src/calibre/ai/open_router/backend.py
@@ -5,13 +5,14 @@ import datetime
import json
import os
import tempfile
+from collections.abc import Iterator, Sequence
from contextlib import closing, suppress
from functools import lru_cache
from threading import Thread
from typing import Any, NamedTuple
from calibre import browser
-from calibre.ai import AICapabilities
+from calibre.ai import AICapabilities, ChatMessage, ChatResponse, NoFreeModels
from calibre.ai.open_router import OpenRouterAI
from calibre.ai.prefs import pref_for_provider
from calibre.constants import __version__, cache_dir
@@ -72,7 +73,7 @@ def schedule_update_of_cached_models_data(cache_loc):
@lru_cache(2)
-def get_available_models():
+def get_available_models() -> dict[str, 'Model']:
cache_loc = os.path.join(cache_dir(), 'openrouter', 'models-v1.json')
with suppress(OSError):
data = json.loads(atomic_read(cache_loc))
@@ -121,6 +122,21 @@ class Model(NamedTuple):
capabilities: AICapabilities
tokenizer: str
+ @property
+ def creator(self) -> str:
+ return self.name.partition(':')[0].lower()
+
+ @property
+ def family(self) -> str:
+ parts = self.name.split(':')
+ if len(parts) > 1:
+ return parts[1].strip().partition(' ')[0].lower()
+ return ''
+
+ @property
+ def name_without_creator(self) -> str:
+ return self.name.partition(':')[-1].lower().strip()
+
@classmethod
def from_dict(cls, x: dict[str, object]) -> 'Model':
arch = x['architecture']
@@ -165,6 +181,72 @@ def is_ready_for_use() -> bool:
return bool(api_key())
+@lru_cache(2)
+def free_model_choice_for_text(allow_paid: bool = False) -> tuple[Model, ...]:
+ gemini_free, gemini_paid = [], []
+ deep_seek_free, deep_seek_paid = [], []
+ gpt5_free, gpt5_paid = [], []
+ gpt_oss_free, gpt_oss_paid = [], []
+ opus_free, opus_paid = [], []
+
+ def only_newest(models: list[Model]) -> tuple[Model, ...]:
+ if models:
+ models.sort(key=lambda m: m.created, reverse=True)
+ return (models[0],)
+ return ()
+
+ def only_cheapest(models: list[Model]) -> tuple[Model, ...]:
+ if models:
+ models.sort(key=lambda m: m.pricing.output_token)
+ return (models[0],)
+ return ()
+
+ for model in get_available_models().values():
+ if AICapabilities.text_to_text not in model.capabilities:
+ continue
+ match model.creator:
+ case 'google':
+ if model.family == 'gemini':
+ gemini_free.append(model) if model.pricing.is_free else gemini_paid.append(model)
+ case 'deepseek':
+ deep_seek_free.append(model) if model.pricing.is_free else deep_seek_paid.append(model)
+ case 'openai':
+ n = model.name_without_creator
+ if n.startswith('gpt-5'):
+ gpt5_free.append(model) if model.pricing.is_free else gpt5_paid.append(model)
+ elif n.startswith('gpt-oss'):
+ gpt_oss_free.append(model) if model.pricing.is_free else gpt_oss_paid.append(model)
+ case 'anthropic':
+ if model.family == 'opus':
+ opus_free.append(model) if model.pricing.is_free else opus_paid.append(model)
+ free = only_newest(gemini_free) + only_newest(gpt5_free) + only_newest(gpt_oss_free) + only_newest(opus_free) + only_newest(deep_seek_free)
+ if free:
+ return free
+ if not allow_paid:
+ raise NoFreeModels(_('No free models were found for text to text generation'))
+ return only_cheapest(gemini_paid) + only_cheapest(gpt5_paid) + only_cheapest(opus_paid) + only_cheapest(deep_seek_paid)
+
+
+def model_choice_for_text() -> Iterator[Model, ...]:
+ match pref('model_choice_strategy', 'free'):
+ case 'free-or-paid':
+ yield from free_model_choice_for_text(allow_paid=True)
+ case 'free-only':
+ yield from free_model_choice_for_text(allow_paid=False)
+ case _:
+ yield get_available_models()['openrouter/auto']
+
+
+def text_chat(messages: Sequence[ChatMessage]) -> Iterator[ChatResponse]:
+ try:
+ models = tuple(model_choice_for_text())
+ except Exception as e:
+ import traceback
+ yield ChatResponse(exception=e, traceback=traceback.format_exc())
+ if not models:
+ models = (get_available_models()['openrouter/auto'],)
+
+
if __name__ == '__main__':
from pprint import pprint
for m in get_available_models().values():
diff --git a/src/calibre/ai/open_router/config.py b/src/calibre/ai/open_router/config.py
index 992b48e89e..4ebd19ec17 100644
--- a/src/calibre/ai/open_router/config.py
+++ b/src/calibre/ai/open_router/config.py
@@ -59,13 +59,13 @@ class Model(QWidget):
l.setContentsMargins(0, 0, 0, 0)
self.for_text = for_text
self.model_id, self.model_name = pref(
- 'text_model' if for_text else 'text_to_image_model', ('', _('Automatic (low cost)')))
+ 'text_model' if for_text else 'text_to_image_model', ('', _('Automatic')))
self.la = la = QLabel(self.model_name)
self.setToolTip(_('The model to use for text related tasks') if for_text else _(
'The model to use for generating images from text'))
self.setToolTip(self.toolTip() + '\n\n' + _(
- 'If not specified an appropriate free to use model is chosen automatically.\n'
- 'If no free model is available then cheaper ones are preferred.'))
+ 'If not specified an appropriate model is chosen automatically.\n'
+ 'See the option for "Model choice strategy" to control how models are automatically chosen.'))
self.b = b = QPushButton(_('&Change'))
b.setToolTip(_('Choose a model'))
l.addWidget(la), l.addWidget(b)
@@ -380,6 +380,24 @@ class ConfigWidget(QWidget):
l.addRow(_('API &key:'), a)
if key := pref('api_key'):
a.setText(from_hex_unicode(key))
+ self.model_strategy = ms = QComboBox(self)
+ l.addRow(_('Model choice strategy:'), ms)
+ ms.addItem(_('Free only'), 'free-only')
+ ms.addItem(_('Free or paid'), 'free-or-paid')
+ ms.addItem(_('High quality'), 'native')
+ if strat := pref('model_choice_strategy'):
+ ms.setCurrentIndex(max(0, ms.findData(strat)))
+ ms.setToolTip('
' + _( + 'The model choice strategy controls how a model to query is chosen when no specific' + ' model is specified. The choices are: