From 48db009d147161c498590d3c5581439b70a1469e Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:55:33 +0530 Subject: [PATCH 1/3] feat(ollama): add configurable options and disable thinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand Ollama provider with three user-facing settings (num_ctx, num_predict, temperature) replacing internal defaults, preventing silent context truncation and unbounded latency. Disable `think` step on reasoning-capable models (qwen3, deepseek-r1, gpt-oss) as they waste latency on internal chain-of-thought. Handle GPT-OSS specially since Ollama only accepts string think levels — "low" approximates "off". Added safe-parse helpers for malformed TextSetting values to fall back to sensible defaults. --- Windows_and_Linux/aiprovider.py | 54 ++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/Windows_and_Linux/aiprovider.py b/Windows_and_Linux/aiprovider.py index 273579e..46f52ca 100644 --- a/Windows_and_Linux/aiprovider.py +++ b/Windows_and_Linux/aiprovider.py @@ -587,9 +587,17 @@ def cancel(self): class OllamaProvider(AIProvider): """ Provider for connecting to an Ollama server. - + Uses the /chat endpoint of the Ollama server to generate a response. Streaming is not used. + + Thinking is explicitly disabled (`think=False`) so reasoning-capable + models like qwen3 / deepseek-r1 / gpt-oss don't burn latency on an + internal chain-of-thought before answering. For GPT-OSS specifically, + Ollama only accepts string levels ("low" / "medium" / "high") instead + of a boolean, so we use "low" — the closest to "off" it offers. + + Reference: https://docs.ollama.com/capabilities/thinking """ def __init__(self, app): self.close_requested = None @@ -598,17 +606,40 @@ def __init__(self, app): settings = [ TextSetting("api_base", "API Base URL", "http://localhost:11434", "E.g. http://localhost:11434"), TextSetting("api_model", "API Model", "llama3.1:8b", "E.g. llama3.1:8b"), - TextSetting("keep_alive", "Time to keep the model loaded in memory in minutes", "5", "E.g. 5") + TextSetting("keep_alive", "Time to keep the model loaded in memory in minutes", "5", "E.g. 5"), + # num_ctx default 4096: Ollama's default of 2048 is too small for our + # system prompt + long user input, which used to cause silent truncation. + TextSetting("num_ctx", "Context window size (num_ctx)", "4096", "E.g. 4096. Larger = more memory, but supports longer inputs."), + # num_predict cap bounds latency and prevents runaway generation on local + # models. Our responses (proofread/rewrite/summary) are short by design. + TextSetting("num_predict", "Max output tokens (num_predict)", "1000", "E.g. 1000. Caps response length to bound latency."), + # 0.4 is a good writing-assistant default — deterministic enough for + # proofreading, not robotic for rewrites. Range is 0.0–1.0. + TextSetting("temperature", "Temperature", "0.4", "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default."), ] super().__init__(app, "Ollama (For Experts)", settings, "• Connect to an Ollama server (local LLM).", "ollama", "Ollama Set-up Instructions", lambda: webbrowser.open("https://github.com/theJayTea/WritingTools?tab=readme-ov-file#-optional-ollama-local-llm-instructions-for-windows-v7-onwards")) + def _safe_int(self, value, default): + """Parse a TextSetting value as int, falling back to `default` on bad input.""" + try: + return int(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + + def _safe_float(self, value, default): + """Parse a TextSetting value as float, falling back to `default` on bad input.""" + try: + return float(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + def get_response(self, system_instruction: str, prompt: str | list, return_response: bool = False) -> str: """ Send a chat request to the Ollama server. - + Always performs a non-streaming request. Returns the response text if return_response is True, otherwise emits it via output_ready_signal. @@ -623,8 +654,23 @@ def get_response(self, system_instruction: str, prompt: str | list, return_respo {"role": "user", "content": prompt} ] + # GPT-OSS only accepts string think levels; everything else accepts bool. + # "low" is the closest to "off" GPT-OSS offers. + think_value = "low" if "gpt-oss" in (self.api_model or "").lower() else False + + options = { + "num_ctx": self._safe_int(getattr(self, "num_ctx", None), 4096), + "num_predict": self._safe_int(getattr(self, "num_predict", None), 1000), + "temperature": self._safe_float(getattr(self, "temperature", None), 0.4), + } + try: - response = self.client.chat(model=self.api_model, messages=messages) + response = self.client.chat( + model=self.api_model, + messages=messages, + think=think_value, + options=options, + ) response_text = response['message']['content'].strip() if not return_response and not hasattr(self.app, 'current_response_window'): self.app.output_ready_signal.emit(response_text) From 327ecaf49e4a8316f3ad5331e325d6b9443621d9 Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:58:34 +0530 Subject: [PATCH 2/3] feat(providers): add Ollama Cloud provider support Add `OllamaCloudProvider` with Bearer API key auth and weekly-quota tier. Register as second default provider (after Gemini) in settings and onboarding. Unify response-generation for local and cloud Ollama via shared OpenAI-style contract, using provider-specific `after_load()` for endpoint/auth. --- Windows_and_Linux/WritingToolApp.py | 27 +- Windows_and_Linux/aiprovider.py | 254 +++++++++++++++++- .../locales/en/LC_MESSAGES/messages.po | 4 + Windows_and_Linux/ui/OnboardingWindow.py | 3 +- Windows_and_Linux/ui/SettingsWindow.py | 133 +++++---- 5 files changed, 365 insertions(+), 56 deletions(-) diff --git a/Windows_and_Linux/WritingToolApp.py b/Windows_and_Linux/WritingToolApp.py index b230a12..ccbef1b 100644 --- a/Windows_and_Linux/WritingToolApp.py +++ b/Windows_and_Linux/WritingToolApp.py @@ -14,7 +14,8 @@ import ui.OnboardingWindow import ui.ResponseWindow import ui.SettingsWindow -from aiprovider import GeminiProvider, OllamaProvider, OpenAICompatibleProvider, obfuscate_api_key +from aiprovider import GeminiProvider, OllamaCloudProvider, OllamaProvider, OpenAICompatibleProvider, obfuscate_api_key +from history_manager import HistoryManager from pynput import keyboard as pykeyboard from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import QLocale, Signal, Slot @@ -93,7 +94,18 @@ def __init__(self, argv): self.setup_ctrl_c_listener() # Setup available AI providers - self.providers = [GeminiProvider(self), OpenAICompatibleProvider(self), OllamaProvider(self)] + # Order matters: the FIRST entry is the default for new installs and + # the order they're presented to the user in the Settings dropdown. + # 1) Gemini (Recommended) — daily-quota free tier, very fast + # 2) Ollama Cloud (Recommended) — weekly-quota free tier, no install + # 3) OpenAI Compatible — for users with their own key + # 4) Ollama Local — for users running Ollama themselves + self.providers = [ + GeminiProvider(self), + OllamaCloudProvider(self), + OpenAICompatibleProvider(self), + OllamaProvider(self), + ] if not self.config: logging.debug('No config found, showing onboarding') @@ -996,8 +1008,13 @@ def process_thread(): return_response=True ) - elif isinstance(self.current_provider, OllamaProvider): # - # For Ollama, prepare messages with system instruction and history + elif isinstance(self.current_provider, (OllamaProvider, OllamaCloudProvider)): + # For both Ollama variants (local server and Ollama Cloud), + # prepare messages with system instruction + history. The + # two providers share the same OpenAI-style message-array + # contract, so they share a branch. The actual endpoint + # (localhost vs ollama.com) and auth are encapsulated in + # each provider's `after_load()`. messages = [{"role": "system", "content": system_instruction}] for msg in history: @@ -1006,7 +1023,7 @@ def process_thread(): "content": msg["content"] }) - # Get response from Ollama + # Get response from Ollama / Ollama Cloud response_text = self.current_provider.get_response( system_instruction, messages, diff --git a/Windows_and_Linux/aiprovider.py b/Windows_and_Linux/aiprovider.py index 46f52ca..c6f97ec 100644 --- a/Windows_and_Linux/aiprovider.py +++ b/Windows_and_Linux/aiprovider.py @@ -2,7 +2,7 @@ AI Provider Architecture for Writing Tools -------------------------------------------- -This module handles different AI model providers (Gemini, OpenAI-compatible, Ollama) and manages their interactions +This module handles different AI model providers (Gemini, Ollama Cloud, OpenAI-compatible, Ollama local) and manages their interactions with the main application. It uses an abstract base class pattern for provider implementations. Key Components: @@ -17,9 +17,11 @@ • Cancelling an ongoing request 3. Provider Implementations: - • GeminiProvider – Uses Google’s Generative AI API (Gemini) to generate content. + • GeminiProvider – Uses Google’s Generative AI API (Gemini) to generate content. + • OllamaCloudProvider – Connects to Ollama Cloud (https://ollama.com) using the ollama Python client + with a Bearer API key. Free tier available — recommended for most users. • OpenAICompatibleProvider – Connects to any OpenAI-compatible API (v1/chat/completions) - • OllamaProvider – Connects to a locally running Ollama server (e.g. for llama.cpp) + • OllamaProvider – Connects to a locally running Ollama server (e.g. for llama.cpp). For Experts. Response Flow: • The main app calls get_response() with a system instruction and a prompt. @@ -257,7 +259,9 @@ def __init__(self, app, provider_name: str, settings: List[AIProviderSetting], description: str = "An unfinished AI provider!", logo: str = "generic", button_text: str = "Go to URL", - button_action: callable = None): + button_action: callable = None, + secondary_button_text: str = None, + secondary_button_action: callable = None): self.provider_name = provider_name self.settings = settings self.app = app @@ -265,6 +269,13 @@ def __init__(self, app, provider_name: str, settings: List[AIProviderSetting], self.logo = logo self.button_text = button_text self.button_action = button_action + # Optional second button next to the primary one (used by Ollama Cloud + # for "Get Free API Key" + "Open API Key Dashboard", and kept generic + # for any future provider that needs a pair of CTAs). Both must be + # provided for the button to render; if either is None we fall back to + # the original single-button layout for backward compatibility. + self.secondary_button_text = secondary_button_text + self.secondary_button_action = secondary_button_action @abstractmethod def get_response(self, system_instruction: str, prompt: str) -> str: @@ -584,9 +595,242 @@ def cancel(self): self.close_requested = True +class OllamaCloudProvider(AIProvider): + """ + Provider for Ollama Cloud (https://ollama.com). + + Recommended for most users: no local install required, just sign in at + ollama.com, generate a free API key, paste it here, and you're set. The + free tier ships with a limited weekly usage budget — comparable in + spirit to Gemini's daily quota but refreshed weekly. + + We use the same `ollama` Python client as the local provider, just + pointing `host` at `https://ollama.com` and passing the API key in the + `Authorization: Bearer ...` header (the `headers=` kwarg of + `ollama.Client`). This means the request shape is identical to local + Ollama — we reuse the same `client.chat(...)` call path, just with + different endpoint and auth. + + References: + • https://docs.ollama.com/cloud + • https://docs.ollama.com/api/authentication + • https://ollama.com/signin (sign up + free key) + • https://ollama.com/settings/keys (manage / regenerate keys) + """ + # Hostname of the Ollama Cloud API. Exposed as a class-level constant + # so tests and tools (e.g. the Settings UI) can reference it without + # hard-coding the string in multiple places. + OLLAMA_CLOUD_HOST = "https://ollama.com" + + def __init__(self, app): + self.close_requested = None + self.client = None + self.app = app + + settings = [ + TextSetting( + name="api_key", + display_name="API Key", + description="Paste your Ollama Cloud API key here", + ), + DropdownSetting( + name="api_model", + display_name="Model", + # Default to gemma4:31b — verified available on Ollama Cloud. + # Users can switch via the dropdown at any time. + default_value="gemma4:31b", + description="Select an Ollama Cloud model to use", + options=[ + # Verified available models on Ollama Cloud (https://ollama.com/api/tags). + # Model IDs use exact tags from the official catalog to avoid 404 errors. + ("gemma4:31b", "gemma4:31b"), + ("gemma3:12b", "gemma3:12b"), + ("deepseek-v4-flash", "deepseek-v4-flash"), + ("nemotron-3-nano:30b", "nemotron-3-nano:30b"), + ("gpt-oss:20b", "gpt-oss:20b") + ], + allow_custom=True, + # The cloud catalog evolves quickly; the Custom escape hatch + # keeps the provider useful as new models land without + # requiring a code update. + custom_placeholder="e.g., gemma4:31b (without -cloud prefix)", + ), + # num_ctx default 4096: matches the local Ollama provider. + TextSetting("num_ctx", "Context window size (num_ctx)", "4096", + "E.g. 4096. Larger = more memory, but supports longer inputs."), + # num_predict cap bounds latency on chatty cloud models. + TextSetting("num_predict", "Max output tokens (num_predict)", "1000", + "E.g. 1000. Caps response length to bound latency."), + # 0.4 is a good writing-assistant default — deterministic enough + # for proofreading, not robotic for rewrites. Range 0.0–1.0. + TextSetting("temperature", "Temperature", "0.4", + "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default."), + ] + + super().__init__( + app, + "Ollama Cloud (Recommended, free tier)", + settings, + # Friendly description for the Settings panel. We mirror the + # Gemini panel's "•"-bulleted style so the two recommended + # providers feel visually consistent. + "• Ollama Cloud lets you run powerful models (Gemma, Gemini, DeepSeek, Nemotron...) with no local install.\n" + "• Sign up for free at ollama.com, generate an API key, and paste it below.\n" + "• The free tier includes a limited amount of usage that refreshes weekly.\n" + "• Click the button below to open your API key dashboard.", + "ollama", + # Single CTA: open the key dashboard directly. Users who need + # to sign up can do so from the same page. + "Open API Key Dashboard", + lambda: webbrowser.open("https://ollama.com/settings/keys"), + ) + + def _safe_int(self, value, default): + """Parse a TextSetting value as int, falling back to `default` on bad input.""" + try: + return int(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + + def _safe_float(self, value, default): + """Parse a TextSetting value as float, falling back to `default` on bad input.""" + try: + return float(value) if str(value).strip() else default + except (ValueError, TypeError): + return default + + def get_response(self, system_instruction: str, prompt, return_response: bool = False) -> str: + """ + Send a chat request to Ollama Cloud. + + Uses the same `/api/chat` schema and response shape as the local + Ollama provider — only the endpoint and auth header differ. The + cloud endpoint uses `Authorization: Bearer `, passed via + the `ollama.Client(headers=...)` kwarg. + + Always performs a non-streaming request. + Returns the response text if return_response is True, otherwise + emits it via output_ready_signal. + """ + self.close_requested = False + + if isinstance(prompt, list): + messages = prompt + else: + messages = [ + {"role": "system", "content": system_instruction}, + {"role": "user", "content": prompt} + ] + + # GPT-OSS only accepts string think levels; everything else accepts + # a bool. "low" is the closest to "off" GPT-OSS offers, and matches + # the local provider's behaviour exactly. (No public Ollama Cloud + # model is named gpt-oss at the moment, but we keep the rule for + # forward-compatibility in case one shows up.) + think_value = "low" if "gpt-oss" in (self.api_model or "").lower() else False + + options = { + "num_ctx": self._safe_int(getattr(self, "num_ctx", None), 4096), + "num_predict": self._safe_int(getattr(self, "num_predict", None), 1000), + "temperature": self._safe_float(getattr(self, "temperature", None), 0.4), + } + + try: + logging.debug(f"Ollama Cloud request: model={self.api_model}, options={options}") + response = self.client.chat( + model=self.api_model, + messages=messages, + think=think_value, + options=options, + ) + response_text = response['message']['content'].strip() + if not return_response and not hasattr(self.app, 'current_response_window'): + self.app.output_ready_signal.emit(response_text) + return response_text + except Exception as e: + error_str = str(e) + logging.error(f"Error during Ollama Cloud chat: {e}") + # Be a little more user-friendly than the local provider's + # generic message: free-tier / quota hits are the most common + # cloud failure mode, and the user can fix them by waiting or + # upgrading at ollama.com — not by editing local config. + if "401" in error_str or "unauthorized" in error_str.lower() or "invalid api key" in error_str.lower(): + self.app.show_message_signal.emit( + "Invalid Ollama Cloud API Key", + "Your Ollama Cloud API key was rejected. " + "Open the API Key Dashboard (button above) to copy a valid key, " + "then paste it here and save again." + ) + elif "402" in error_str or "quota" in error_str.lower() or "usage limit" in error_str.lower(): + self.app.show_message_signal.emit( + "Ollama Cloud Free Tier Limit", + "You've used up this week's free-tier quota on Ollama Cloud. " + "It will reset on a rolling weekly basis, or you can upgrade your plan at ollama.com." + ) + else: + self.app.output_ready_signal.emit("An error occurred during Ollama Cloud chat.") + return "" + + def after_load(self): + """ + Build the Ollama Cloud client. + + The host is fixed to `https://ollama.com`; the API key is passed in + the `Authorization: Bearer ...` header via the `headers=` kwarg + (the official cloud-auth pattern documented at + https://docs.ollama.com/api/authentication). + """ + # Guard against an empty key at startup — `ollama.Client` would + # happily build without one and only fail at request time. We + # still build the client (so the rest of the provider's lifecycle + # works) but tag it so we can short-circuit on send. + headers = {'Authorization': f'Bearer {self.api_key}'} if self.api_key else {} + self.client = OllamaClient(host=self.OLLAMA_CLOUD_HOST, headers=headers) + + def before_load(self): + self.client = None + + def load_config(self, config: dict): + """ + Load configuration, deobfuscating the API key if needed. + Mirrors the Gemini provider's pattern so an existing user upgrading + from plaintext storage (or migrating in from a different machine) + still gets a working key. + """ + if 'api_key' in config: + config = config.copy() # Don't mutate the caller's dict + config['api_key'] = deobfuscate_api_key(config['api_key']) + super().load_config(config) + + def save_config(self): + """ + Save configuration, obfuscating the API key for storage. + Same XOR+base64 obfuscation Gemini uses — at-rest defence in + depth only (anyone with the obfuscated blob can trivially reverse + it on the same machine), but it stops a casual Ctrl+F in the + config file from harvesting live keys. + """ + config = {} + for setting in self.settings: + value = setting.get_value() + if setting.name == 'api_key': + value = obfuscate_api_key(value) + config[setting.name] = value + self.app.config["providers"][self.provider_name] = config + self.app.save_config(self.app.config) + + def cancel(self): + self.close_requested = True + + class OllamaProvider(AIProvider): """ - Provider for connecting to an Ollama server. + Provider for connecting to a LOCAL Ollama server. + + This is the "For Experts" option — it requires the user to install + and run `ollama serve` on their own machine and to pull a model + locally. If you just want to get up and running, use Ollama Cloud + (the recommended provider listed above this one in Settings). Uses the /chat endpoint of the Ollama server to generate a response. Streaming is not used. diff --git a/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po b/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po index 2a8e717..be3f710 100644 --- a/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po +++ b/Windows_and_Linux/locales/en/LC_MESSAGES/messages.po @@ -165,6 +165,10 @@ msgid "Gemini 2.0" msgstr "" #: ui/OnboardingWindow.py:54 ui/OnboardingWindow.py:56 +msgid "Ollama Cloud (free tier — no install needed)" +msgstr "" + +#: ui/OnboardingWindow.py:55 ui/OnboardingWindow.py:57 msgid "ANY OpenAI Compatible API — including local LLMs!" msgstr "" diff --git a/Windows_and_Linux/ui/OnboardingWindow.py b/Windows_and_Linux/ui/OnboardingWindow.py index 6b62f80..bc13cb2 100644 --- a/Windows_and_Linux/ui/OnboardingWindow.py +++ b/Windows_and_Linux/ui/OnboardingWindow.py @@ -44,7 +44,7 @@ def show_welcome_screen(self): self.content_layout.addWidget(title_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) features_text = f""" - • {_('Instantly optimize your writing with AI by selecting your text and invoking Writing Tools with "ctrl+space", anywhere.')} + • {_('Instantly optimize your writing with AI by selecting your text and invoking Writing Tools with "ctrl+space", anywhere.')} • {_('Get a summary you can chat with of articles, YouTube videos, or documents by select all text with "ctrl+a"')} {_('(or select the YouTube transcript from its description), invoking Writing Tools, and choosing Summary.')} @@ -53,6 +53,7 @@ def show_welcome_screen(self): • {_('Supports an extensive range of AI models:')} - {_('Gemini 2.0')} + - {_('Ollama Cloud (free tier — no install needed)')} - {_('ANY OpenAI Compatible API — including local LLMs!')} """ features_label = QtWidgets.QLabel(features_text) diff --git a/Windows_and_Linux/ui/SettingsWindow.py b/Windows_and_Linux/ui/SettingsWindow.py index e3a77b2..a39536f 100644 --- a/Windows_and_Linux/ui/SettingsWindow.py +++ b/Windows_and_Linux/ui/SettingsWindow.py @@ -74,64 +74,47 @@ def init_provider_ui(self, provider: AIProvider, layout): description_label.setWordWrap(True) self.current_provider_layout.addWidget(description_label) - if hasattr(provider, 'ollama_button_text'): - # Create container for buttons + # The Settings panel supports up to TWO side-by-side CTA buttons under + # the provider description. We trigger the two-button layout in two + # ways, in order of preference: + # 1. The provider exposes a generic `secondary_button_text` + + # `secondary_button_action` pair (Ollama Cloud uses this). + # 2. The provider exposes the legacy `ollama_button_text` pair + # (kept for any external providers that may still use it). + # Providers that only expose a single `button_text` get the original + # single centred button — no behaviour change for Gemini, OpenAI, or + # the local Ollama provider. + secondary_text = getattr(provider, 'secondary_button_text', None) + secondary_action = getattr(provider, 'secondary_button_action', None) + if secondary_text and secondary_action: + # Generic two-button row (case 1) — preferred. + self._render_two_buttons( + provider.button_text, provider.button_action, + secondary_text, secondary_action, + self.current_provider_layout, + ) + elif hasattr(provider, 'ollama_button_text'): + # Legacy two-button row (case 2) — backwards compatibility. button_layout = QtWidgets.QHBoxLayout() - - # Add Ollama setup button + + # Add legacy Ollama setup button ollama_button = QtWidgets.QPushButton(provider.ollama_button_text) - ollama_button.setStyleSheet(f""" - QPushButton {{ - background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; - color: white; - padding: 10px; - font-size: 16px; - border: none; - border-radius: 5px; - }} - QPushButton:hover {{ - background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; - }} - """) + ollama_button.setStyleSheet(self._button_stylesheet()) ollama_button.clicked.connect(provider.ollama_button_action) button_layout.addWidget(ollama_button) - + # Add original button main_button = QtWidgets.QPushButton(provider.button_text) - main_button.setStyleSheet(f""" - QPushButton {{ - background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; - color: white; - padding: 10px; - font-size: 16px; - border: none; - border-radius: 5px; - }} - QPushButton:hover {{ - background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; - }} - """) + main_button.setStyleSheet(self._button_stylesheet()) main_button.clicked.connect(provider.button_action) button_layout.addWidget(main_button) - + self.current_provider_layout.addLayout(button_layout) else: # Original single button logic if provider.button_text: button = QtWidgets.QPushButton(provider.button_text) - button.setStyleSheet(f""" - QPushButton {{ - background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; - color: white; - padding: 10px; - font-size: 16px; - border: none; - border-radius: 5px; - }} - QPushButton:hover {{ - background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; - }} - """) + button.setStyleSheet(self._button_stylesheet()) button.clicked.connect(provider.button_action) self.current_provider_layout.addWidget(button, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) @@ -344,6 +327,66 @@ def init_ui(self): desired_height = min(720, max_height) # Cap at 720px or 85% of screen height self.resize(592, desired_height) # Use an exact width of 592px so stuff looks good! + @staticmethod + def _button_stylesheet(): + """ + Shared QSS for the primary/secondary CTA buttons rendered in a + provider's settings panel. Centralised here so both the single-button + and the two-button paths look identical, and so any future theme + tweak happens in one place. + + The colour shifts slightly with the system theme via `colorMode`: + • dark → green (#4CAF50 / #45a049) + • light → blue (#008CBA / #007095) + """ + return f""" + QPushButton {{ + background-color: {'#4CAF50' if colorMode == 'dark' else '#008CBA'}; + color: white; + padding: 10px; + font-size: 16px; + border: none; + border-radius: 5px; + }} + QPushButton:hover {{ + background-color: {'#45a049' if colorMode == 'dark' else '#007095'}; + }} + """ + + def _render_two_buttons(self, + primary_text, primary_action, + secondary_text, secondary_action, + parent_layout): + """ + Render the two-button CTA row used by providers that need more than + one action next to their description (currently only Ollama Cloud, + with "Get Free API Key" + "Open API Key Dashboard"). Buttons share + the row 50/50 via stretch factors so a long primary label doesn't + crowd the secondary one. + + Caller is expected to have validated that all four arguments are + non-None before calling. + """ + button_layout = QtWidgets.QHBoxLayout() + button_layout.setSpacing(10) + + # Primary button (left) — e.g. "Get Free API Key". Treated as the + # visually dominant action. + primary_button = QtWidgets.QPushButton(primary_text) + primary_button.setStyleSheet(self._button_stylesheet()) + primary_button.clicked.connect(primary_action) + button_layout.addWidget(primary_button, stretch=1) + + # Secondary button (right) — e.g. "Open API Key Dashboard". Same + # styling; the 50/50 split is purely to make the two equal-weight + # CTAs feel balanced rather than to dim the secondary. + secondary_button = QtWidgets.QPushButton(secondary_text) + secondary_button.setStyleSheet(self._button_stylesheet()) + secondary_button.clicked.connect(secondary_action) + button_layout.addWidget(secondary_button, stretch=1) + + parent_layout.addLayout(button_layout) + @staticmethod def toggle_autostart(state): """Toggle the autostart setting.""" From b4455449ef9094c49f5e89976e9899c983f965d3 Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:41:02 +0530 Subject: [PATCH 3/3] feat(ollama-cloud): add reasoning effort setting and refresh model list - add reasoning_effort dropdown (off/low/medium/high) mapping "off" to think=False, with fallback to "low" for thinking-only models like glm-5.3-flash - update model catalog with display labels, add glm-5.3-flash, pin deepseek-v4-flash:0731, drop gpt-oss:20b - simplify custom model placeholder --- Windows_and_Linux/aiprovider.py | 409 +++++++++++++++++++++++--------- 1 file changed, 295 insertions(+), 114 deletions(-) diff --git a/Windows_and_Linux/aiprovider.py b/Windows_and_Linux/aiprovider.py index c6f97ec..f37d36e 100644 --- a/Windows_and_Linux/aiprovider.py +++ b/Windows_and_Linux/aiprovider.py @@ -60,8 +60,8 @@ def obfuscate_api_key(key: str) -> str: """ if not key or key.startswith(_OBFUSCATION_PREFIX): return key # Already obfuscated or empty - xored = bytes([b ^ _XOR_KEY for b in key.encode('utf-8')]) - return _OBFUSCATION_PREFIX + base64.b64encode(xored).decode('ascii') + xored = bytes([b ^ _XOR_KEY for b in key.encode("utf-8")]) + return _OBFUSCATION_PREFIX + base64.b64encode(xored).decode("ascii") def deobfuscate_api_key(obfuscated: str) -> str: @@ -71,16 +71,23 @@ def deobfuscate_api_key(obfuscated: str) -> str: """ if not obfuscated or not obfuscated.startswith(_OBFUSCATION_PREFIX): return obfuscated # Not obfuscated, return as-is - encoded = obfuscated[len(_OBFUSCATION_PREFIX):] + encoded = obfuscated[len(_OBFUSCATION_PREFIX) :] xored = base64.b64decode(encoded) - return bytes([b ^ _XOR_KEY for b in xored]).decode('utf-8') + return bytes([b ^ _XOR_KEY for b in xored]).decode("utf-8") class AIProviderSetting(ABC): """ Abstract base class for a provider setting (e.g., API key, model selection). """ - def __init__(self, name: str, display_name: str = None, default_value: str = None, description: str = None): + + def __init__( + self, + name: str, + display_name: str = None, + default_value: str = None, + description: str = None, + ): self.name = name self.display_name = display_name if display_name else name self.default_value = default_value if default_value else "" @@ -106,7 +113,14 @@ class TextSetting(AIProviderSetting): """ A text-based setting (for API keys, URLs, etc.). """ - def __init__(self, name: str, display_name: str = None, default_value: str = None, description: str = None): + + def __init__( + self, + name: str, + display_name: str = None, + default_value: str = None, + description: str = None, + ): super().__init__(name, display_name, default_value, description) self.internal_value = default_value self.input = None @@ -114,15 +128,17 @@ def __init__(self, name: str, display_name: str = None, default_value: str = Non def render_to_layout(self, layout: QVBoxLayout): row_layout = QtWidgets.QHBoxLayout() label = QtWidgets.QLabel(self.display_name) - label.setStyleSheet(f"font-size: 16px; color: {'#ffffff' if colorMode=='dark' else '#333333'};") + label.setStyleSheet( + f"font-size: 16px; color: {'#ffffff' if colorMode == 'dark' else '#333333'};" + ) row_layout.addWidget(label) self.input = QtWidgets.QLineEdit(self.internal_value) self.input.setStyleSheet(f""" font-size: 16px; padding: 5px; - background-color: {'#444' if colorMode=='dark' else 'white'}; - color: {'#ffffff' if colorMode=='dark' else '#000000'}; - border: 1px solid {'#666' if colorMode=='dark' else '#ccc'}; + background-color: {"#444" if colorMode == "dark" else "white"}; + color: {"#ffffff" if colorMode == "dark" else "#000000"}; + border: 1px solid {"#666" if colorMode == "dark" else "#ccc"}; """) self.input.setPlaceholderText(self.description) row_layout.addWidget(self.input) @@ -143,12 +159,20 @@ class DropdownSetting(AIProviderSetting): When allow_custom=True, users can select "Custom" from the dropdown and enter any value. If the loaded config value doesn't match any preset option, "Custom" is auto-selected. """ + # Sentinel value used internally to identify the "Custom" dropdown option _CUSTOM_SENTINEL = "__custom__" - def __init__(self, name: str, display_name: str = None, default_value: str = None, - description: str = None, options: list = None, allow_custom: bool = False, - custom_placeholder: str = "Enter custom value"): + def __init__( + self, + name: str, + display_name: str = None, + default_value: str = None, + description: str = None, + options: list = None, + allow_custom: bool = False, + custom_placeholder: str = "Enter custom value", + ): super().__init__(name, display_name, default_value, description) self.options = options if options else [] self.internal_value = default_value @@ -161,15 +185,17 @@ def __init__(self, name: str, display_name: str = None, default_value: str = Non def render_to_layout(self, layout: QVBoxLayout): row_layout = QtWidgets.QHBoxLayout() label = QtWidgets.QLabel(self.display_name) - label.setStyleSheet(f"font-size: 16px; color: {'#ffffff' if colorMode=='dark' else '#333333'};") + label.setStyleSheet( + f"font-size: 16px; color: {'#ffffff' if colorMode == 'dark' else '#333333'};" + ) row_layout.addWidget(label) self.dropdown = QtWidgets.QComboBox() self.dropdown.setStyleSheet(f""" font-size: 16px; padding: 5px; - background-color: {'#444' if colorMode=='dark' else 'white'}; - color: {'#ffffff' if colorMode=='dark' else '#000000'}; - border: 1px solid {'#666' if colorMode=='dark' else '#ccc'}; + background-color: {"#444" if colorMode == "dark" else "white"}; + color: {"#ffffff" if colorMode == "dark" else "#000000"}; + border: 1px solid {"#666" if colorMode == "dark" else "#ccc"}; """) # Add preset options @@ -205,13 +231,16 @@ def render_to_layout(self, layout: QVBoxLayout): self.custom_input.setStyleSheet(f""" font-size: 16px; padding: 5px; - background-color: {'#444' if colorMode=='dark' else 'white'}; - color: {'#ffffff' if colorMode=='dark' else '#000000'}; - border: 1px solid {'#666' if colorMode=='dark' else '#ccc'}; + background-color: {"#444" if colorMode == "dark" else "white"}; + color: {"#ffffff" if colorMode == "dark" else "#000000"}; + border: 1px solid {"#666" if colorMode == "dark" else "#ccc"}; """) # If current value is custom (not in presets), populate the input - if self.dropdown.currentData() == self._CUSTOM_SENTINEL and self.internal_value: + if ( + self.dropdown.currentData() == self._CUSTOM_SENTINEL + and self.internal_value + ): self.custom_input.setText(self.internal_value) custom_row_layout.addWidget(self.custom_input) @@ -248,20 +277,26 @@ def get_value(self): class AIProvider(ABC): """ Abstract base class for AI providers. - + All providers must implement: • get_response(system_instruction, prompt) -> str • after_load() to create their client or model instance • before_load() to cleanup any existing client • cancel() to cancel an ongoing request """ - def __init__(self, app, provider_name: str, settings: List[AIProviderSetting], - description: str = "An unfinished AI provider!", - logo: str = "generic", - button_text: str = "Go to URL", - button_action: callable = None, - secondary_button_text: str = None, - secondary_button_action: callable = None): + + def __init__( + self, + app, + provider_name: str, + settings: List[AIProviderSetting], + description: str = "An unfinished AI provider!", + logo: str = "generic", + button_text: str = "Go to URL", + button_action: callable = None, + secondary_button_text: str = None, + secondary_button_action: callable = None, + ): self.provider_name = provider_name self.settings = settings self.app = app @@ -348,10 +383,18 @@ class GeminiProvider(AIProvider): # Disable safety filtering across all categories (best-effort; some models # may still soft-refuse). Defined once, reused per request. _SAFETY_SETTINGS = [ - genai_types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"), - genai_types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"), - genai_types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"), - genai_types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"), + genai_types.SafetySetting( + category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE" + ), + genai_types.SafetySetting( + category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE" + ), + genai_types.SafetySetting( + category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE" + ), + genai_types.SafetySetting( + category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE" + ), ] def __init__(self, app): @@ -359,7 +402,11 @@ def __init__(self, app): self.client = None settings = [ - TextSetting(name="api_key", display_name="API Key", description="Paste your Gemini API key here"), + TextSetting( + name="api_key", + display_name="API Key", + description="Paste your Gemini API key here", + ), DropdownSetting( name="model_name", display_name="Model", @@ -370,26 +417,38 @@ def __init__(self, app): # points to Gemini 3 Flash Preview — fast (~1–2s) and high # quality. Capped at 20 free requests/day per the model's free # tier. - ("⭐ Gemini Flash Latest (very fast | only 20 free uses/day)", "gemini-flash-latest"), + ( + "⭐ Gemini Flash Latest (very fast | only 20 free uses/day)", + "gemini-flash-latest", + ), # Gemma 4 models are unlimited on the free tier but noticeably # slower (8–15s typical) since they run on different # infrastructure. ("Gemma 4 31B (slow | unlimited free use)", "gemma-4-31b-it"), - ("Gemma 4 26B A4B (slow | unlimited free use)", "gemma-4-26b-a4b-it"), + ( + "Gemma 4 26B A4B (slow | unlimited free use)", + "gemma-4-26b-a4b-it", + ), ], allow_custom=True, - custom_placeholder="e.g., gemini-3.1-pro-preview" - ) + custom_placeholder="e.g., gemini-3.1-pro-preview", + ), ] - super().__init__(app, "Gemini (Recommended)", settings, + super().__init__( + app, + "Gemini (Recommended)", + settings, "• Google's Gemini is a powerful AI model available for free!\n" "• An API key is required to connect to Gemini on your behalf.\n" "• Click the button below to get your API key.", "gemini", "Get API Key", - lambda: webbrowser.open("https://aistudio.google.com/app/apikey")) + lambda: webbrowser.open("https://aistudio.google.com/app/apikey"), + ) - def _build_config(self, system_instruction: str) -> "genai_types.GenerateContentConfig": + def _build_config( + self, system_instruction: str + ) -> "genai_types.GenerateContentConfig": """ Build a per-call GenerateContentConfig. @@ -416,7 +475,9 @@ def _build_config(self, system_instruction: str) -> "genai_types.GenerateContent "max_output_tokens": 1000, } if not is_gemma: - kwargs["thinking_config"] = genai_types.ThinkingConfig(thinking_level="minimal") + kwargs["thinking_config"] = genai_types.ThinkingConfig( + thinking_level="minimal" + ) return genai_types.GenerateContentConfig(**kwargs) @staticmethod @@ -435,10 +496,16 @@ def _messages_to_contents(messages: list) -> list: continue text = m.get("content", "") gemini_role = "model" if role == "assistant" else "user" - contents.append(genai_types.Content(role=gemini_role, parts=[genai_types.Part(text=text)])) + contents.append( + genai_types.Content( + role=gemini_role, parts=[genai_types.Part(text=text)] + ) + ) return contents - def get_response(self, system_instruction: str, prompt, return_response: bool = False) -> str: + def get_response( + self, system_instruction: str, prompt, return_response: bool = False + ) -> str: """ Generate content using Gemini. @@ -452,7 +519,11 @@ def get_response(self, system_instruction: str, prompt, return_response: bool = self.close_requested = False try: - contents = self._messages_to_contents(prompt) if isinstance(prompt, list) else prompt + contents = ( + self._messages_to_contents(prompt) + if isinstance(prompt, list) + else prompt + ) response = self.client.models.generate_content( model=self.model_name, @@ -460,16 +531,18 @@ def get_response(self, system_instruction: str, prompt, return_response: bool = config=self._build_config(system_instruction), ) - response_text = (response.text or "").rstrip('\n') + response_text = (response.text or "").rstrip("\n") - if not return_response and not hasattr(self.app, 'current_response_window'): + if not return_response and not hasattr(self.app, "current_response_window"): self.app.output_ready_signal.emit(response_text) self.app.replace_text(True) return "" return response_text except Exception as e: logging.error(f"Error processing Gemini response: {e}") - self.app.output_ready_signal.emit("An error occurred while processing the response.") + self.app.output_ready_signal.emit( + "An error occurred while processing the response." + ) return "" finally: self.close_requested = False @@ -479,9 +552,9 @@ def load_config(self, config: dict): Load configuration, deobfuscating the API key if needed. """ # Deobfuscate API key before loading - if 'api_key' in config: + if "api_key" in config: config = config.copy() # Don't modify the original - config['api_key'] = deobfuscate_api_key(config['api_key']) + config["api_key"] = deobfuscate_api_key(config["api_key"]) super().load_config(config) def save_config(self): @@ -492,7 +565,7 @@ def save_config(self): for setting in self.settings: value = setting.get_value() # Obfuscate API key before saving - if setting.name == 'api_key': + if setting.name == "api_key": value = obfuscate_api_key(value) config[setting.name] = value self.app.config["providers"][self.provider_name] = config @@ -516,30 +589,55 @@ def cancel(self): class OpenAICompatibleProvider(AIProvider): """ Provider for OpenAI-compatible APIs. - + Uses self.client.chat.completions.create() to obtain a response. Streaming is fully removed. """ + def __init__(self, app): self.close_requested = None self.client = None settings = [ - TextSetting(name="api_key", display_name="API Key", description="API key for the OpenAI-compatible API."), - TextSetting("api_base", "API Base URL", "https://api.openai.com/v1", "E.g. https://api.openai.com/v1"), - TextSetting("api_organisation", "API Organisation", "", "Leave blank if not applicable."), - TextSetting("api_project", "API Project", "", "Leave blank if not applicable."), + TextSetting( + name="api_key", + display_name="API Key", + description="API key for the OpenAI-compatible API.", + ), + TextSetting( + "api_base", + "API Base URL", + "https://api.openai.com/v1", + "E.g. https://api.openai.com/v1", + ), + TextSetting( + "api_organisation", + "API Organisation", + "", + "Leave blank if not applicable.", + ), + TextSetting( + "api_project", "API Project", "", "Leave blank if not applicable." + ), TextSetting("api_model", "API Model", "gpt-4o-mini", "E.g. gpt-4o-mini"), ] - super().__init__(app, "OpenAI Compatible (For Experts)", settings, + super().__init__( + app, + "OpenAI Compatible (For Experts)", + settings, "• Connect to ANY OpenAI-compatible API (v1/chat/completions).\n" "• You must abide by the service's Terms of Service.", - "openai", "Get OpenAI API Key", lambda: webbrowser.open("https://platform.openai.com/account/api-keys")) + "openai", + "Get OpenAI API Key", + lambda: webbrowser.open("https://platform.openai.com/account/api-keys"), + ) - def get_response(self, system_instruction: str, prompt: str | list, return_response: bool = False) -> str: + def get_response( + self, system_instruction: str, prompt: str | list, return_response: bool = False + ) -> str: """ Send a chat request to the OpenAI-compatible API. - + Always performs a non-streaming request. If prompt is not a list, builds a simple two-message conversation. Returns the response text if return_response is True, @@ -552,19 +650,16 @@ def get_response(self, system_instruction: str, prompt: str | list, return_respo else: messages = [ {"role": "system", "content": system_instruction}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ] try: response = self.client.chat.completions.create( - model=self.api_model, - messages=messages, - temperature=0.5, - stream=False + model=self.api_model, messages=messages, temperature=0.5, stream=False ) response_text = response.choices[0].message.content.strip() - if not return_response and not hasattr(self.app, 'current_response_window'): + if not return_response and not hasattr(self.app, "current_response_window"): self.app.output_ready_signal.emit(response_text) return response_text @@ -574,10 +669,12 @@ def get_response(self, system_instruction: str, prompt: str | list, return_respo if "exceeded" in error_str or "rate limit" in error_str: self.app.show_message_signal.emit( "Rate Limit Hit", - "It appears you have hit an API rate/usage limit. Please try again later or adjust your settings." + "It appears you have hit an API rate/usage limit. Please try again later or adjust your settings.", ) else: - self.app.show_message_signal.emit("Error", f"An error occurred: {error_str}") + self.app.show_message_signal.emit( + "Error", f"An error occurred: {error_str}" + ) return "" def after_load(self): @@ -585,7 +682,7 @@ def after_load(self): api_key=self.api_key, base_url=self.api_base, organization=self.api_organisation, - project=self.api_project + project=self.api_project, ) def before_load(self): @@ -617,6 +714,7 @@ class OllamaCloudProvider(AIProvider): • https://ollama.com/signin (sign up + free key) • https://ollama.com/settings/keys (manage / regenerate keys) """ + # Hostname of the Ollama Cloud API. Exposed as a class-level constant # so tests and tools (e.g. the Settings UI) can reference it without # hard-coding the string in multiple places. @@ -642,29 +740,55 @@ def __init__(self, app): description="Select an Ollama Cloud model to use", options=[ # Verified available models on Ollama Cloud (https://ollama.com/api/tags). - # Model IDs use exact tags from the official catalog to avoid 404 errors. - ("gemma4:31b", "gemma4:31b"), - ("gemma3:12b", "gemma3:12b"), - ("deepseek-v4-flash", "deepseek-v4-flash"), - ("nemotron-3-nano:30b", "nemotron-3-nano:30b"), - ("gpt-oss:20b", "gpt-oss:20b") + # (display label, exact model ID from the official catalog). + ("Gemma 4 (31B, very fast)", "gemma4:31b"), + ("GLM 5.3 Flash (18B active)", "glm-5.3-flash"), + ("DeepSeek V4 Flash (13B active, 0731)", "deepseek-v4-flash:0731"), + ("Nemotron 3 Nano (30B)", "nemotron-3-nano:30b"), ], allow_custom=True, # The cloud catalog evolves quickly; the Custom escape hatch # keeps the provider useful as new models land without # requiring a code update. - custom_placeholder="e.g., gemma4:31b (without -cloud prefix)", + custom_placeholder="e.g., gemma4:31b", + ), + DropdownSetting( + name="reasoning_effort", + display_name="Reasoning Effort", + # "low" is the lowest string level the Ollama API accepts + # (low/medium/high/max); "off" maps to think=False. Models + # without thinking support ignore it harmlessly. + default_value="off", + description="How much thinking the model does before answering", + options=[ + ("Off (fastest)", "off"), + ("Low", "low"), + ("Medium", "medium"), + ("High", "high"), + ], ), # num_ctx default 4096: matches the local Ollama provider. - TextSetting("num_ctx", "Context window size (num_ctx)", "4096", - "E.g. 4096. Larger = more memory, but supports longer inputs."), + TextSetting( + "num_ctx", + "Context window size (num_ctx)", + "4096", + "E.g. 4096. Larger = more memory, but supports longer inputs.", + ), # num_predict cap bounds latency on chatty cloud models. - TextSetting("num_predict", "Max output tokens (num_predict)", "1000", - "E.g. 1000. Caps response length to bound latency."), + TextSetting( + "num_predict", + "Max output tokens (num_predict)", + "1000", + "E.g. 1000. Caps response length to bound latency.", + ), # 0.4 is a good writing-assistant default — deterministic enough # for proofreading, not robotic for rewrites. Range 0.0–1.0. - TextSetting("temperature", "Temperature", "0.4", - "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default."), + TextSetting( + "temperature", + "Temperature", + "0.4", + "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default.", + ), ] super().__init__( @@ -699,7 +823,9 @@ def _safe_float(self, value, default): except (ValueError, TypeError): return default - def get_response(self, system_instruction: str, prompt, return_response: bool = False) -> str: + def get_response( + self, system_instruction: str, prompt, return_response: bool = False + ) -> str: """ Send a chat request to Ollama Cloud. @@ -719,15 +845,23 @@ def get_response(self, system_instruction: str, prompt, return_response: bool = else: messages = [ {"role": "system", "content": system_instruction}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ] - # GPT-OSS only accepts string think levels; everything else accepts - # a bool. "low" is the closest to "off" GPT-OSS offers, and matches - # the local provider's behaviour exactly. (No public Ollama Cloud - # model is named gpt-oss at the moment, but we keep the rule for - # forward-compatibility in case one shows up.) - think_value = "low" if "gpt-oss" in (self.api_model or "").lower() else False + # Reasoning effort: "off" maps to think=False. Most models support + # the boolean off (per models.dev), but thinking-only models — where + # reasoning is always on (e.g. glm-5.3-flash, only low/high/max) — + # fall back to "low", the lowest level they offer. + _THINKING_ONLY_MODELS = ("glm-5.3-flash",) + effort = (getattr(self, "reasoning_effort", None) or "off").lower() + model = (self.api_model or "").lower() + if effort == "off": + if any(t in model for t in _THINKING_ONLY_MODELS): + think_value = "low" + else: + think_value = False + else: + think_value = effort options = { "num_ctx": self._safe_int(getattr(self, "num_ctx", None), 4096), @@ -736,15 +870,17 @@ def get_response(self, system_instruction: str, prompt, return_response: bool = } try: - logging.debug(f"Ollama Cloud request: model={self.api_model}, options={options}") + logging.debug( + f"Ollama Cloud request: model={self.api_model}, options={options}" + ) response = self.client.chat( model=self.api_model, messages=messages, think=think_value, options=options, ) - response_text = response['message']['content'].strip() - if not return_response and not hasattr(self.app, 'current_response_window'): + response_text = response["message"]["content"].strip() + if not return_response and not hasattr(self.app, "current_response_window"): self.app.output_ready_signal.emit(response_text) return response_text except Exception as e: @@ -754,21 +890,31 @@ def get_response(self, system_instruction: str, prompt, return_response: bool = # generic message: free-tier / quota hits are the most common # cloud failure mode, and the user can fix them by waiting or # upgrading at ollama.com — not by editing local config. - if "401" in error_str or "unauthorized" in error_str.lower() or "invalid api key" in error_str.lower(): + if ( + "401" in error_str + or "unauthorized" in error_str.lower() + or "invalid api key" in error_str.lower() + ): self.app.show_message_signal.emit( "Invalid Ollama Cloud API Key", "Your Ollama Cloud API key was rejected. " "Open the API Key Dashboard (button above) to copy a valid key, " - "then paste it here and save again." + "then paste it here and save again.", ) - elif "402" in error_str or "quota" in error_str.lower() or "usage limit" in error_str.lower(): + elif ( + "402" in error_str + or "quota" in error_str.lower() + or "usage limit" in error_str.lower() + ): self.app.show_message_signal.emit( "Ollama Cloud Free Tier Limit", "You've used up this week's free-tier quota on Ollama Cloud. " - "It will reset on a rolling weekly basis, or you can upgrade your plan at ollama.com." + "It will reset on a rolling weekly basis, or you can upgrade your plan at ollama.com.", ) else: - self.app.output_ready_signal.emit("An error occurred during Ollama Cloud chat.") + self.app.output_ready_signal.emit( + "An error occurred during Ollama Cloud chat." + ) return "" def after_load(self): @@ -784,7 +930,7 @@ def after_load(self): # happily build without one and only fail at request time. We # still build the client (so the rest of the provider's lifecycle # works) but tag it so we can short-circuit on send. - headers = {'Authorization': f'Bearer {self.api_key}'} if self.api_key else {} + headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} self.client = OllamaClient(host=self.OLLAMA_CLOUD_HOST, headers=headers) def before_load(self): @@ -797,9 +943,9 @@ def load_config(self, config: dict): from plaintext storage (or migrating in from a different machine) still gets a working key. """ - if 'api_key' in config: + if "api_key" in config: config = config.copy() # Don't mutate the caller's dict - config['api_key'] = deobfuscate_api_key(config['api_key']) + config["api_key"] = deobfuscate_api_key(config["api_key"]) super().load_config(config) def save_config(self): @@ -813,7 +959,7 @@ def save_config(self): config = {} for setting in self.settings: value = setting.get_value() - if setting.name == 'api_key': + if setting.name == "api_key": value = obfuscate_api_key(value) config[setting.name] = value self.app.config["providers"][self.provider_name] = config @@ -843,28 +989,61 @@ class OllamaProvider(AIProvider): Reference: https://docs.ollama.com/capabilities/thinking """ + def __init__(self, app): self.close_requested = None self.client = None self.app = app settings = [ - TextSetting("api_base", "API Base URL", "http://localhost:11434", "E.g. http://localhost:11434"), + TextSetting( + "api_base", + "API Base URL", + "http://localhost:11434", + "E.g. http://localhost:11434", + ), TextSetting("api_model", "API Model", "llama3.1:8b", "E.g. llama3.1:8b"), - TextSetting("keep_alive", "Time to keep the model loaded in memory in minutes", "5", "E.g. 5"), + TextSetting( + "keep_alive", + "Time to keep the model loaded in memory in minutes", + "5", + "E.g. 5", + ), # num_ctx default 4096: Ollama's default of 2048 is too small for our # system prompt + long user input, which used to cause silent truncation. - TextSetting("num_ctx", "Context window size (num_ctx)", "4096", "E.g. 4096. Larger = more memory, but supports longer inputs."), + TextSetting( + "num_ctx", + "Context window size (num_ctx)", + "4096", + "E.g. 4096. Larger = more memory, but supports longer inputs.", + ), # num_predict cap bounds latency and prevents runaway generation on local # models. Our responses (proofread/rewrite/summary) are short by design. - TextSetting("num_predict", "Max output tokens (num_predict)", "1000", "E.g. 1000. Caps response length to bound latency."), + TextSetting( + "num_predict", + "Max output tokens (num_predict)", + "1000", + "E.g. 1000. Caps response length to bound latency.", + ), # 0.4 is a good writing-assistant default — deterministic enough for # proofreading, not robotic for rewrites. Range is 0.0–1.0. - TextSetting("temperature", "Temperature", "0.4", "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default."), + TextSetting( + "temperature", + "Temperature", + "0.4", + "0.0 = deterministic, 1.0 = creative. 0.4 is a good writing-assistant default.", + ), ] - super().__init__(app, "Ollama (For Experts)", settings, + super().__init__( + app, + "Ollama (For Experts)", + settings, "• Connect to an Ollama server (local LLM).", - "ollama", "Ollama Set-up Instructions", - lambda: webbrowser.open("https://github.com/theJayTea/WritingTools?tab=readme-ov-file#-optional-ollama-local-llm-instructions-for-windows-v7-onwards")) + "ollama", + "Ollama Set-up Instructions", + lambda: webbrowser.open( + "https://github.com/theJayTea/WritingTools?tab=readme-ov-file#-optional-ollama-local-llm-instructions-for-windows-v7-onwards" + ), + ) def _safe_int(self, value, default): """Parse a TextSetting value as int, falling back to `default` on bad input.""" @@ -880,7 +1059,9 @@ def _safe_float(self, value, default): except (ValueError, TypeError): return default - def get_response(self, system_instruction: str, prompt: str | list, return_response: bool = False) -> str: + def get_response( + self, system_instruction: str, prompt: str | list, return_response: bool = False + ) -> str: """ Send a chat request to the Ollama server. @@ -895,7 +1076,7 @@ def get_response(self, system_instruction: str, prompt: str | list, return_respo else: messages = [ {"role": "system", "content": system_instruction}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ] # GPT-OSS only accepts string think levels; everything else accepts bool. @@ -915,8 +1096,8 @@ def get_response(self, system_instruction: str, prompt: str | list, return_respo think=think_value, options=options, ) - response_text = response['message']['content'].strip() - if not return_response and not hasattr(self.app, 'current_response_window'): + response_text = response["message"]["content"].strip() + if not return_response and not hasattr(self.app, "current_response_window"): self.app.output_ready_signal.emit(response_text) return response_text except Exception as e: