Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions Windows_and_Linux/WritingToolApp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 = []

Expand Down
168 changes: 168 additions & 0 deletions Windows_and_Linux/aiprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
5 changes: 5 additions & 0 deletions macOS/WritingTools/App/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions macOS/WritingTools/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
}
Expand Down
19 changes: 16 additions & 3 deletions macOS/WritingTools/Models/Providers/OpenAIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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"
}
Expand Down
7 changes: 1 addition & 6 deletions macOS/WritingTools/Views/Onboarding/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -137,19 +132,31 @@ 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
_ = AXIsProcessTrustedWithOptions(options)

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")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down