From 1ae937edee2798140995afd2dad6260d7cdb6b29 Mon Sep 17 00:00:00 2001 From: Hermanito Ed Date: Mon, 23 Mar 2026 19:59:21 +0800 Subject: [PATCH 1/4] feat: add OpenAI Responses API provider Add OpenAIResponsesProvider as a selectable provider option alongside the existing Gemini, OpenAI Compatible, and Ollama providers. Key differences from the existing OpenAICompatibleProvider: - Uses /v1/responses endpoint (not /v1/chat/completions) - Input field is 'input' (list of message dicts or string) - Output parsed from response.output[0].content[0].text - Stateful multi-turn via previous_response_id (no full history replay) Changes: - Windows_and_Linux/aiprovider.py: add OpenAIResponsesProvider class - API key, base URL, model dropdown (gpt-4o-mini / gpt-4o / o3-mini / o1 + custom) - Obfuscated key storage matching existing pattern - Stateful follow-up caching via _last_response_id - Windows_and_Linux/WritingToolApp.py: - Import and register OpenAIResponsesProvider - Add provider-specific branch in process_followup_question for stateful Responses API multi-turn --- Windows_and_Linux/WritingToolApp.py | 21 +++- Windows_and_Linux/aiprovider.py | 168 ++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 3 deletions(-) diff --git a/Windows_and_Linux/WritingToolApp.py b/Windows_and_Linux/WritingToolApp.py index 61fd293..2c62ac9 100644 --- a/Windows_and_Linux/WritingToolApp.py +++ b/Windows_and_Linux/WritingToolApp.py @@ -14,7 +14,7 @@ import ui.OnboardingWindow import ui.ResponseWindow import ui.SettingsWindow -from aiprovider import GeminiProvider, OllamaProvider, OpenAICompatibleProvider, obfuscate_api_key +from aiprovider import GeminiProvider, OllamaProvider, OpenAICompatibleProvider, OpenAIResponsesProvider, obfuscate_api_key from pynput import keyboard as pykeyboard from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import QLocale, Signal, Slot @@ -72,7 +72,7 @@ def __init__(self, argv): self.setup_ctrl_c_listener() # Setup available AI providers - self.providers = [GeminiProvider(self), OpenAICompatibleProvider(self), OllamaProvider(self)] + self.providers = [GeminiProvider(self), OpenAICompatibleProvider(self), OpenAIResponsesProvider(self), OllamaProvider(self)] if not self.config: logging.debug('No config found, showing onboarding') @@ -758,7 +758,22 @@ def process_thread(): logging.debug('Sending request to AI provider') # Format conversation differently based on provider - if isinstance(self.current_provider, GeminiProvider): + if isinstance(self.current_provider, OpenAIResponsesProvider): + # Responses API supports stateful chaining via previous_response_id. + # Build a messages list and pass the cached id when available. + messages = [{"role": "system", "content": system_instruction}] + for msg in history: + role = "assistant" if msg["role"] == "assistant" else "user" + messages.append({"role": role, "content": msg["content"]}) + + response_text = self.current_provider.get_response( + system_instruction, + messages, + return_response=True, + previous_response_id=self.current_provider._last_response_id + ) + + elif isinstance(self.current_provider, GeminiProvider): # For Gemini, use the proper history format with roles chat_messages = [] diff --git a/Windows_and_Linux/aiprovider.py b/Windows_and_Linux/aiprovider.py index 4fd7d0d..55148c4 100644 --- a/Windows_and_Linux/aiprovider.py +++ b/Windows_and_Linux/aiprovider.py @@ -575,3 +575,171 @@ def before_load(self): def cancel(self): self.close_requested = True + + +class OpenAIResponsesProvider(AIProvider): + """ + Provider for the OpenAI Responses API (/v1/responses). + + The Responses API differs from the Chat Completions API: + • Endpoint: POST /v1/responses (not /v1/chat/completions) + • Input: 'input' field (list of message dicts or a plain string) + • Output: response.output[0].content[0].text + • Multi-turn: stateful via 'previous_response_id' – no need to replay full history + + Settings: + api_key – OpenAI (or compatible) API key + api_base – Base URL, default https://api.openai.com/v1 + api_model – Model, default gpt-4o-mini + """ + + def __init__(self, app): + self.close_requested = False + self.client = None + self._last_response_id = None # stateful multi-turn tracking + + settings = [ + TextSetting( + name="api_key", + display_name="API Key", + description="Your OpenAI API key (or compatible service key)." + ), + TextSetting( + name="api_base", + display_name="API Base URL", + default_value="https://api.openai.com/v1", + description="Base URL for the Responses API endpoint." + ), + DropdownSetting( + name="api_model", + display_name="Model", + default_value="gpt-4o-mini", + description="Model to use with the Responses API.", + options=[ + ("gpt-4o-mini (fast & cheap)", "gpt-4o-mini"), + ("gpt-4o (powerful)", "gpt-4o"), + ("o3-mini (reasoning)", "o3-mini"), + ("o1 (advanced reasoning)", "o1"), + ], + allow_custom=True, + custom_placeholder="e.g. gpt-4.1 or your custom model name" + ), + ] + super().__init__( + app, + "OpenAI Responses API", + settings, + "• Uses OpenAI's newer Responses API (/v1/responses).\n" + "• Supports stateful multi-turn conversations natively.\n" + "• Compatible with gpt-4o, gpt-4o-mini, o3-mini, o1, and more.\n" + "• Click the button below to get your API key.", + "openai", + "Get OpenAI API Key", + lambda: webbrowser.open("https://platform.openai.com/account/api-keys") + ) + + # ------------------------------------------------------------------ + # Configuration helpers + # ------------------------------------------------------------------ + + def load_config(self, config: dict): + if "api_key" in config: + config = config.copy() + config["api_key"] = deobfuscate_api_key(config["api_key"]) + super().load_config(config) + + def save_config(self): + 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) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def after_load(self): + """Create the OpenAI client pointed at the configured base URL.""" + self.client = OpenAI( + api_key=self.api_key, + base_url=self.api_base, + ) + self._last_response_id = None + + def before_load(self): + self.client = None + self._last_response_id = None + + def cancel(self): + self.close_requested = True + + # ------------------------------------------------------------------ + # Core request + # ------------------------------------------------------------------ + + def get_response( + self, + system_instruction: str, + prompt, + return_response: bool = False, + previous_response_id: str = None + ) -> str: + """ + Send a request to the Responses API and return the text. + + Args: + system_instruction: System-level instruction string. + prompt: Either a plain string (single-turn) or a list of + {"role": ..., "content": ...} dicts (multi-turn fallback). + return_response: If True return the text directly; otherwise emit via signal. + previous_response_id: Responses API stateful chaining id. + """ + self.close_requested = False + + # Build the input list for the Responses API + if isinstance(prompt, list): + # Multi-turn list passed from process_followup_question. + # The Responses API accepts the same message-dict format when passed + # under the 'input' key, so we can use it directly. + input_messages = prompt + else: + # Single-turn: system instruction + user message + input_messages = [ + {"role": "system", "content": system_instruction}, + {"role": "user", "content": prompt}, + ] + + # Build kwargs – only pass previous_response_id when we have one + kwargs = { + "model": self.api_model, + "input": input_messages, + } + if previous_response_id: + kwargs["previous_response_id"] = previous_response_id + + try: + response = self.client.responses.create(**kwargs) + # Extract text from the Responses API output format + response_text = response.output[0].content[0].text.strip() + # Cache the response id for potential stateful follow-ups + self._last_response_id = getattr(response, "id", None) + + 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"OpenAIResponsesProvider error: {error_str}") + if "rate limit" in error_str.lower() or "exceeded" in error_str.lower(): + self.app.show_message_signal.emit( + "Rate Limit Hit", + "You've hit an API rate or usage limit. Please try again later or adjust your settings." + ) + else: + self.app.show_message_signal.emit("Error", f"An error occurred: {error_str}") + return "" From 8787814db57eb6a2fb2b6475fb8f2aab22735fb3 Mon Sep 17 00:00:00 2001 From: edzhou Date: Mon, 23 Mar 2026 20:14:12 +0800 Subject: [PATCH 2/4] fix: use fallback URL schemes for Privacy & Security settings across macOS versions The x-apple.systemsettings: scheme stopped working on macOS 26+. Consolidate all Privacy pane opening logic into a single helper that tries x-apple.systempreferences: first and falls back to x-apple.systemsettings: for older versions. Co-Authored-By: Claude Opus 4.6 --- .../Views/Onboarding/OnboardingView.swift | 7 +---- .../Views/OnboardingPermissionsStep.swift | 31 ++++++++++++------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/macOS/WritingTools/Views/Onboarding/OnboardingView.swift b/macOS/WritingTools/Views/Onboarding/OnboardingView.swift index 6d322e2..b5c34a8 100644 --- a/macOS/WritingTools/Views/Onboarding/OnboardingView.swift +++ b/macOS/WritingTools/Views/Onboarding/OnboardingView.swift @@ -159,12 +159,7 @@ import ApplicationServices } private func openPrivacyPane(anchor: String) { - if let url = URL( - string: - "x-apple.systemsettings:com.apple.settings.PrivacySecurity.extension?\(anchor)" - ) { - NSWorkspace.shared.open(url) - } + OnboardingPermissionsHelper.openPrivacyPane(anchor: anchor) } private func openCommandsManager() { diff --git a/macOS/WritingTools/Views/Onboarding/Views/OnboardingPermissionsStep.swift b/macOS/WritingTools/Views/Onboarding/Views/OnboardingPermissionsStep.swift index 3191d98..28d629d 100644 --- a/macOS/WritingTools/Views/Onboarding/Views/OnboardingPermissionsStep.swift +++ b/macOS/WritingTools/Views/Onboarding/Views/OnboardingPermissionsStep.swift @@ -118,12 +118,7 @@ struct OnboardingPermissionsStep: View { Spacer() Button("Open Privacy & Security") { - if let url = URL( - string: - "x-apple.systemsettings:com.apple.settings.PrivacySecurity.extension" - ) { - NSWorkspace.shared.open(url) - } + OnboardingPermissionsHelper.openPrivacyPane() } .buttonStyle(.link) .accessibilityLabel("Open Privacy and Security settings") @@ -137,6 +132,23 @@ struct OnboardingPermissionsStep: View { // MARK: - Permission Helpers struct OnboardingPermissionsHelper { + /// Opens a Privacy & Security pane, trying the legacy `systempreferences:` scheme first + /// (works on macOS 14–26+) and falling back to the `systemsettings:` scheme (macOS 13–15). + @discardableResult + static func openPrivacyPane(anchor: String? = nil) -> Bool { + let suffix = anchor.map { "?\($0)" } ?? "" + let urls = [ + "x-apple.systempreferences:com.apple.preference.security\(suffix)", + "x-apple.systemsettings:com.apple.settings.PrivacySecurity.extension\(suffix)", + ] + for string in urls { + if let url = URL(string: string), NSWorkspace.shared.open(url) { + return true + } + } + return false + } + static func requestAccessibility() { let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as CFString let options: CFDictionary = [key: true] as CFDictionary @@ -144,12 +156,7 @@ struct OnboardingPermissionsHelper { Task { @MainActor in try? await Task.sleep(for: .milliseconds(200)) - if let url = URL( - string: - "x-apple.systemsettings:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility" - ) { - NSWorkspace.shared.open(url) - } + openPrivacyPane(anchor: "Privacy_Accessibility") } } From 1db3c26270dcc9997fbbd00a8b1ecee75b8d5230 Mon Sep 17 00:00:00 2001 From: edzhou Date: Mon, 23 Mar 2026 20:26:43 +0800 Subject: [PATCH 3/4] fix: change default OpenAI base URL to include /v1 path Custom base URLs without /v1 (e.g. https://newapi.example.com) would result in requests to /chat/completions instead of /v1/chat/completions, hitting the web UI instead of the API and causing "Failed to parse API response" errors. Also add placeholder hint in the Base URL text field. Co-Authored-By: Claude Opus 4.6 --- .../Models/Providers/OpenAIProvider.swift | 19 ++++++++++++++++--- .../Providers/OpenAISettingsView.swift | 8 +++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/macOS/WritingTools/Models/Providers/OpenAIProvider.swift b/macOS/WritingTools/Models/Providers/OpenAIProvider.swift index f1fc304..1653f97 100644 --- a/macOS/WritingTools/Models/Providers/OpenAIProvider.swift +++ b/macOS/WritingTools/Models/Providers/OpenAIProvider.swift @@ -8,8 +8,9 @@ struct OpenAIConfig: Codable, Sendable { var apiKey: String var baseURL: String var model: String - - static let defaultBaseURL = "https://api.openai.com" + var forceStreaming: Bool = false + + static let defaultBaseURL = "https://api.openai.com/v1" static let defaultModel = "gpt-5.2" } @@ -125,12 +126,24 @@ final class OpenAIProvider: AIProvider { // MARK: - Custom Request Implementation private static func performCustomOpenAIRequest(config: OpenAIConfig, systemPrompt: String?, userPrompt: String, images: [Data]) async throws -> String { + // When the API requires streaming, collect SSE chunks into a single result + if config.forceStreaming { + var result = "" + try await performCustomOpenAIStreamingRequest( + config: config, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + images: images, + onChunk: { chunk in result += chunk } + ) + return result + } + // Construct URL var urlString = config.baseURL if urlString.hasSuffix("/") { urlString = String(urlString.dropLast()) } - // Append /chat/completions if not present (simple heuristic, can be improved) if !urlString.hasSuffix("/chat/completions") { urlString += "/chat/completions" } diff --git a/macOS/WritingTools/Views/Settings/Providers/OpenAISettingsView.swift b/macOS/WritingTools/Views/Settings/Providers/OpenAISettingsView.swift index 1843c40..0ea258e 100644 --- a/macOS/WritingTools/Views/Settings/Providers/OpenAISettingsView.swift +++ b/macOS/WritingTools/Views/Settings/Providers/OpenAISettingsView.swift @@ -25,7 +25,7 @@ struct OpenAISettingsView: View { needsSaving = true } - TextField("Base URL", text: $settings.openAIBaseURL) + TextField("Base URL (e.g. https://api.openai.com/v1)", text: $settings.openAIBaseURL) .textFieldStyle(.roundedBorder) .onChange(of: settings.openAIBaseURL) { _, _ in needsSaving = true @@ -47,6 +47,12 @@ struct OpenAISettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + + Toggle("Force Streaming", isOn: $settings.openAIForceStreaming) + .onChange(of: settings.openAIForceStreaming) { _, _ in + needsSaving = true + } + .help("Enable this if your API provider requires streaming responses (e.g. some third-party proxies).") } .padding(.bottom, 4) From 43ff3151dfc7aed6362b03162ef8275f6ed35844 Mon Sep 17 00:00:00 2001 From: edzhou Date: Mon, 23 Mar 2026 20:26:50 +0800 Subject: [PATCH 4/4] feat: add Force Streaming option for OpenAI-compatible providers Some third-party API proxies require stream=true and reject non-streaming requests. Add a toggle in OpenAI settings that, when enabled, uses SSE streaming internally and accumulates chunks into a single response. Co-Authored-By: Claude Opus 4.6 --- macOS/WritingTools/App/AppSettings.swift | 5 +++++ macOS/WritingTools/App/AppState.swift | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/macOS/WritingTools/App/AppSettings.swift b/macOS/WritingTools/App/AppSettings.swift index 54fbf63..2d46c7b 100644 --- a/macOS/WritingTools/App/AppSettings.swift +++ b/macOS/WritingTools/App/AppSettings.swift @@ -65,6 +65,10 @@ final class AppSettings { var openAIProject: String? { didSet { defaults.set(openAIProject, forKey: "openai_project") } } + + var openAIForceStreaming: Bool { + didSet { defaults.set(openAIForceStreaming, forKey: "openai_force_streaming") } + } var currentProvider: String { didSet { defaults.set(currentProvider, forKey: "current_provider") } @@ -202,6 +206,7 @@ final class AppSettings { self.openAIModel = defaults.string(forKey: "openai_model") ?? OpenAIConfig.defaultModel self.openAIOrganization = defaults.string(forKey: "openai_organization") self.openAIProject = defaults.string(forKey: "openai_project") + self.openAIForceStreaming = defaults.bool(forKey: "openai_force_streaming") self.mistralApiKey = keychain.bootstrapRetrieve(forKey: "mistral_api_key") ?? "" self.mistralBaseURL = defaults.string(forKey: "mistral_base_url") ?? MistralConfig.defaultBaseURL diff --git a/macOS/WritingTools/App/AppState.swift b/macOS/WritingTools/App/AppState.swift index 4a3bb15..029db3b 100644 --- a/macOS/WritingTools/App/AppState.swift +++ b/macOS/WritingTools/App/AppState.swift @@ -161,7 +161,8 @@ final class AppState { let config = OpenAIConfig( apiKey: asettings.openAIApiKey, baseURL: asettings.openAIBaseURL, - model: model + model: model, + forceStreaming: asettings.openAIForceStreaming ) provider = OpenAIProvider(config: config) @@ -281,7 +282,8 @@ final class AppState { let openAIConfig = OpenAIConfig( apiKey: asettings.openAIApiKey, baseURL: asettings.openAIBaseURL, - model: asettings.openAIModel + model: asettings.openAIModel, + forceStreaming: asettings.openAIForceStreaming ) self.openAIProvider = OpenAIProvider(config: openAIConfig) @@ -370,7 +372,7 @@ final class AppState { asettings.openAIProject = project asettings.openAIModel = model - let config = OpenAIConfig(apiKey: apiKey, baseURL: baseURL, model: model) + let config = OpenAIConfig(apiKey: apiKey, baseURL: baseURL, model: model, forceStreaming: asettings.openAIForceStreaming) openAIProvider = OpenAIProvider(config: config) clearProviderCache() }