diff --git a/src/calibre/ai/config.py b/src/calibre/ai/config.py index d4c51404bf..78564863e8 100644 --- a/src/calibre/ai/config.py +++ b/src/calibre/ai/config.py @@ -4,8 +4,7 @@ from qt.core import QComboBox, QDialog, QGroupBox, QHBoxLayout, QLabel, QStackedLayout, QVBoxLayout, QWidget from calibre.ai import AICapabilities -from calibre.ai.prefs import prefs -from calibre.customize.ui import available_ai_provider_plugins +from calibre.ai.prefs import plugins_for_purpose, prefs from calibre.gui2 import Application, error_dialog @@ -13,7 +12,7 @@ class ConfigureAI(QWidget): def __init__(self, purpose: AICapabilities = AICapabilities.text_to_text, parent: QWidget | None = None): super().__init__(parent) - plugins = tuple(p for p in available_ai_provider_plugins() if p.capabilities & purpose == purpose) + plugins = tuple(plugins_for_purpose(purpose)) self.available_plugins = plugins self.purpose = purpose self.plugin_config_widgets: tuple[QWidget, ...] = tuple(p.config_widget() for p in plugins) diff --git a/src/calibre/ai/open_router/backend.py b/src/calibre/ai/open_router/backend.py index 71640a824f..831d028282 100644 --- a/src/calibre/ai/open_router/backend.py +++ b/src/calibre/ai/open_router/backend.py @@ -8,16 +8,22 @@ import tempfile from contextlib import closing, suppress from functools import lru_cache from threading import Thread -from typing import NamedTuple +from typing import Any, NamedTuple from calibre import browser from calibre.ai import AICapabilities +from calibre.ai.open_router import OpenRouterAI +from calibre.ai.prefs import pref_for_provider from calibre.constants import __version__, cache_dir from calibre.utils.lock import SingleInstance module_version = 1 # needed for live updates +def pref(key: str, defval: Any = None) -> Any: + return pref_for_provider(OpenRouterAI.name, key, defval) + + def get_browser(): ans = browser(user_agent=f'calibre {__version__}') return ans @@ -151,6 +157,14 @@ def save_settings(config_widget): config_widget.save_settings() +def api_key() -> str: + return pref('api_key') + + +def is_ready_for_use() -> bool: + return bool(api_key()) + + if __name__ == '__main__': from pprint import pprint for m in get_available_models().values(): diff --git a/src/calibre/ai/prefs.py b/src/calibre/ai/prefs.py index b0a38e48bb..5ce01ccbbb 100644 --- a/src/calibre/ai/prefs.py +++ b/src/calibre/ai/prefs.py @@ -1,10 +1,14 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2025, Kovid Goyal +from collections.abc import Iterator from copy import deepcopy from functools import lru_cache from typing import Any +from calibre.ai import AICapabilities +from calibre.customize import AIProviderPlugin +from calibre.customize.ui import available_ai_provider_plugins from calibre.utils.config import JSONConfig @@ -24,3 +28,19 @@ def set_prefs_for_provider(name: str, pref_map: dict[str, Any]) -> None: p = prefs() p['providers'][name] = deepcopy(pref_map) p.set('providers', p['providers']) + + +def plugins_for_purpose(purpose: AICapabilities) -> Iterator[AIProviderPlugin]: + for p in sorted(available_ai_provider_plugins(), key=lambda p: (p.priority, p.name.lower())): + if p.capabilities & purpose == purpose: + yield p + + +def plugin_for_purpose(purpose: AICapabilities) -> AIProviderPlugin | None: + compatible_plugins = {p.name: p for p in plugins_for_purpose(purpose)} + q = prefs()['purpose_map'].get(str(purpose), '') + if ans := compatible_plugins.get(q): + return ans + if compatible_plugins: + return next(iter(compatible_plugins.values())) + return None diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py index b89cc4ce07..de20683f84 100644 --- a/src/calibre/customize/__init__.py +++ b/src/calibre/customize/__init__.py @@ -846,6 +846,12 @@ class AIProviderPlugin(Plugin): # {{{ ans = self._builtin_live_module = load_module(self.builtin_live_module_name, strategy=Strategy.fast) return ans + @property + def is_ready_for_use(self) -> bool: + if not self.builtin_live_module_name: + return False + return self.builtin_live_module.is_ready_for_use() + def initialize(self): self._builtin_live_module = None diff --git a/src/calibre/gui2/viewer/llm.py b/src/calibre/gui2/viewer/llm.py index ba1f0afebb..e1f579669a 100644 --- a/src/calibre/gui2/viewer/llm.py +++ b/src/calibre/gui2/viewer/llm.py @@ -34,7 +34,9 @@ from qt.core import ( pyqtSignal, ) +from calibre.ai import AICapabilities from calibre.ai.config import ConfigureAI +from calibre.ai.prefs import plugin_for_purpose from calibre.ebooks.metadata import authors_to_string from calibre.gui2 import Application, error_dialog from calibre.gui2.dialogs.confirm_delete import confirm @@ -42,7 +44,6 @@ from calibre.gui2.viewer.config import vprefs from calibre.gui2.viewer.highlights import HighlightColorCombo from calibre.gui2.widgets2 import Dialog from calibre.utils.icu import primary_sort_key -from polyglot.binary import from_hex_unicode # --- Backend Abstraction & Cost Data --- MODEL_COSTS = { @@ -115,14 +116,10 @@ API_PROVIDERS = { class LLMAPICall(Thread): - def __init__(self, conversation_history, api_key, model_id, signal_emitter, provider_config): - super().__init__() + def __init__(self, conversation_history, signal_emitter): + super().__init__(daemon=True) self.conversation_history = conversation_history - self.api_key = api_key - self.model_id = model_id self.signal_emitter = signal_emitter - self.provider_config = provider_config - self.daemon = True def run(self): try: @@ -299,13 +296,16 @@ class LLMPanel(QWidget): dialog.actions_updated.connect(self.rebuild_actions_ui) dialog.exec() + @property + def is_ready_for_use(self) -> bool: + p = plugin_for_purpose(AICapabilities.text_to_text) + return p is not None and p.is_ready_for_use + def show_initial_message(self): self.save_note_button.setEnabled(False) - api_key_hex = vprefs.get('llm_api_key', '') or '' - api_key = from_hex_unicode(api_key_hex) - if not api_key: + if not self.is_ready_for_use: self.show_response('

' + _( - 'Please add your API key for an AI service by clicking the Settings button below.'), {}) + 'Please configure an AI provider by clicking the Settings button below.'), {}) else: self.show_response(_('Select text in the book to begin.'), {}) @@ -399,10 +399,9 @@ class LLMPanel(QWidget): return html_output def start_api_call(self, action_prompt): - api_key_hex = vprefs.get('llm_api_key', '') or '' - api_key = from_hex_unicode(api_key_hex) - if not api_key: - self.show_response(f"

{_('API Key Missing.')} Click the {_('Settings')} button to add your key.

", {}) + if not self.is_ready_for_use: + self.show_response(f"

{_('AI provider not configured')} Click the {_( + 'Settings')} button to configure an AI service provider.

", {}) return if not self.latched_conversation_text: self.show_response(f"

{_('Error')}: {_('No text is selected for this conversation.')}

", {}) @@ -438,11 +437,7 @@ class LLMPanel(QWidget): self.result_display.verticalScrollBar().setValue(self.result_display.verticalScrollBar().maximum()) self.set_all_inputs_enabled(False) - model_id = vprefs.get('llm_model_id', 'google/gemini-1.5-flash') - provider_config = API_PROVIDERS['openrouter'] - api_call_thread = LLMAPICall( - api_call_history, api_key, model_id, self.response_received, provider_config - ) + api_call_thread = LLMAPICall(api_call_history, self.response_received) api_call_thread.start() def show_response(self, response_text, usage_data):