Skip to content
Merged
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
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ It is fully open source under the MIT license. The hosted service is a **$5/mont

**Don't want to subscribe? Bring your own API key** — Anthropic, OpenAI, or OpenRouter — and SuperPaste is genuinely free. It works in the paid build and in builds you compile from source, sending screenshots straight from your Mac to your chosen provider. No telemetry either way.

You can also point SuperPaste at **any OpenAI-compatible endpoint**, including local models (Ollama, LM Studio, vLLM).
Want nothing to leave your Mac? Pick **Ollama (local)** or **LM Studio (local)** in Settings. No API key, no account: SuperPaste finds the server on its default port, lists the models you have installed, and runs the screenshot through one of them on-device. Any other **OpenAI-compatible endpoint** (vLLM, llama.cpp, a gateway) works through the Custom provider, with the API key optional. See [Run fully local](#run-fully-local-ollama-or-lm-studio).

---

Expand Down Expand Up @@ -102,10 +102,32 @@ The reset command is also available directly:
./bin/reset-onboarding.sh --permissions
```

## Run fully local (Ollama or LM Studio)

SuperPaste can run entirely on your Mac. The screenshot goes to a model server on `localhost` and never touches the network.

1. Install [Ollama](https://ollama.com/download) or [LM Studio](https://lmstudio.ai) and pull a vision-capable model. Vision matters: SuperPaste sends a screenshot, not text.
```bash
ollama pull qwen3-vl # or: gemma4, llama3.2-vision
```
In LM Studio, download a vision model (Qwen3-VL, Gemma 4) and start the local server from the Developer tab.
2. In SuperPaste, open **Settings → AI Provider** and choose **Ollama (local)** or **LM Studio (local)**.
3. SuperPaste checks the server (`localhost:11434` for Ollama, `localhost:1234` for LM Studio), shows "Connected" with the installed models, and marks the ones that can read screenshots. Leave the model on **Auto** to use the first vision model it finds, or pick one.

That's the whole setup. No API key field to fill, no trial clock, no subscription. Everything the app knows about the server (host and port, chosen model) is visible in **Settings → Diagnostics**.

Notes:

- Moved the server to another port or machine? Enter it in the **Server address** field. Any of `localhost:11434`, `http://host:port/v1`, or a full `/v1/chat/completions` URL is accepted. SuperPaste tells you whether the address is on this Mac or over the network.
- Turned on authentication in LM Studio, or put a gateway in front of Ollama? Expand **API key (optional)** and save the key; it stays in the macOS Keychain.
- Other OpenAI-compatible servers (vLLM, llama.cpp `llama-server`, Jan, MLX servers) work through **Custom (OpenAI-compatible)**. The key is optional there too, and the same model list and connection check apply when the server exposes `/v1/models`.
- A text-only model will answer, but it cannot see the screenshot. SuperPaste warns when the selected model doesn't look vision-capable.
- Local models are smaller than the hosted Claude model, so expect plainer replies, and give the first request a few extra seconds while the model loads.

## Privacy

- SuperPaste captures one active-window screenshot only when you press Option+V.
- The screenshot is sent to your chosen AI provider for generation and immediately discarded after processing. With the hosted plan it goes through the SuperPaste backend; with a bring-your-own-key it goes directly from your Mac to the provider — the SuperPaste backend never sees it.
- The screenshot is sent to your chosen AI provider for generation and immediately discarded after processing. With the hosted plan it goes through the SuperPaste backend; with a bring-your-own-key it goes directly from your Mac to the provider — the SuperPaste backend never sees it. With a local provider (Ollama, LM Studio) it never leaves your Mac at all.
- Accessibility is used for the global hotkey and the final `⌘V` paste.
- No analytics, no telemetry, no crash reporting. If something goes wrong, please file an issue.

Expand Down
9 changes: 6 additions & 3 deletions SuperPaste/Sources/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ final class AppState: ObservableObject {
/// Whether a valid license is stored locally (Keychain). Drives UI in Settings.
@Published private(set) var isLicensed: Bool = LicenseService.shared.hasLocalLicense

/// Whether any bring-your-own-key provider is active.
@Published private(set) var usingOwnAPIKey: Bool = UserCredentialStore.anyKeyActive
/// Whether the selected provider serves pastes outside the hosted backend:
/// a bring-your-own key, or a keyless local server (Ollama, LM Studio).
/// Drives trial enforcement, so it must track the selected provider rather
/// than "some key exists somewhere".
@Published private(set) var usingOwnAPIKey: Bool = LLMService.currentConfig().isOwnProviderReady

/// Which provider is currently configured.
@Published private(set) var providerConfig: LLMProviderConfig = LLMService.currentConfig()
Expand Down Expand Up @@ -268,7 +271,7 @@ final class AppState: ObservableObject {

private func computeTrialDaysRemaining() {
isLicensed = LicenseService.shared.hasLocalLicense
usingOwnAPIKey = UserCredentialStore.anyKeyActive
usingOwnAPIKey = providerConfig.isOwnProviderReady

if isLicensed || usingOwnAPIKey {
trialDaysRemaining = nil
Expand Down
125 changes: 112 additions & 13 deletions SuperPaste/Sources/Models/LLMProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ enum LLMProviderID: String, CaseIterable, Identifiable, Codable {
case anthropic
case openai
case openrouter
case ollama
case lmstudio
case custom

var id: String { rawValue }
Expand All @@ -16,6 +18,8 @@ enum LLMProviderID: String, CaseIterable, Identifiable, Codable {
case .anthropic: return "Anthropic"
case .openai: return "OpenAI"
case .openrouter: return "OpenRouter"
case .ollama: return "Ollama (local)"
case .lmstudio: return "LM Studio (local)"
case .custom: return "Custom (OpenAI-compatible)"
}
}
Expand All @@ -26,63 +30,155 @@ enum LLMProviderID: String, CaseIterable, Identifiable, Codable {
case .anthropic: return "Screenshots go directly to Anthropic. Uses your Anthropic API key."
case .openai: return "Screenshots go directly to OpenAI. Uses your OpenAI API key."
case .openrouter: return "Screenshots go to the model you select via OpenRouter. Uses your OpenRouter API key."
case .custom: return "Screenshots go to any OpenAI-compatible endpoint. Provide the base URL and API key."
case .ollama: return "Runs on this Mac through Ollama. Screenshots never leave your computer. No API key, no account."
case .lmstudio: return "Runs on this Mac through LM Studio's local server. Screenshots never leave your computer. No API key, no account."
case .custom: return "Screenshots go to any OpenAI-compatible endpoint. Provide the base URL; the API key is optional for servers that don't need one."
}
}

var isBringYourOwnKey: Bool {
self != .superpaste
}

/// Local inference servers run on this Mac and are configured by endpoint
/// rather than by credential.
var isLocal: Bool {
self == .ollama || self == .lmstudio
}

/// Whether a saved API key is mandatory before the provider can be used.
/// Local servers (Ollama, LM Studio) accept requests without one, and a
/// custom endpoint may or may not be protected, so the key is optional there.
var requiresAPIKey: Bool {
switch self {
case .superpaste, .ollama, .lmstudio, .custom: return false
case .anthropic, .openai, .openrouter: return true
}
}

/// Whether the user can edit the endpoint URL.
var supportsCustomEndpoint: Bool {
isLocal || self == .custom
}

/// Whether the endpoint exposes an OpenAI-style `GET /v1/models` list that
/// the Settings model picker can read.
var supportsModelDiscovery: Bool {
supportsCustomEndpoint
}

/// Default model identifier for this provider when a user hasn't set one.
/// Local providers leave this empty: the model is picked from whatever the
/// server actually has installed (see `LocalModelServer.pickDefaultModel`).
var defaultModel: String {
switch self {
case .superpaste: return "claude-sonnet-5"
case .anthropic: return "claude-sonnet-4"
case .openai: return "gpt-4o"
case .openrouter: return "openai/gpt-4o"
case .custom: return ""
case .ollama, .lmstudio, .custom: return ""
}
}

var supportsCustomModel: Bool {
self != .superpaste
}

/// The endpoint to POST to (nil for superpaste which uses APIConfig.baseURL).
/// The endpoint to POST to (empty for superpaste, which uses APIConfig.baseURL,
/// and for custom, where the user supplies it). Local providers return the
/// server's default chat-completions URL; the user can override it.
var endpointURL: String {
switch self {
case .superpaste: return ""
case .anthropic: return "https://api.anthropic.com/v1/messages"
case .openai: return "https://api.openai.com/v1/chat/completions"
case .openrouter: return "https://openrouter.ai/api/v1/chat/completions"
case .ollama: return "http://localhost:11434/v1/chat/completions"
case .lmstudio: return "http://localhost:1234/v1/chat/completions"
case .custom: return "" // user-provided
}
}

/// Link to get an API key.
/// Placeholder shown in the endpoint field.
var endpointPlaceholder: String {
switch self {
case .ollama: return "http://localhost:11434"
case .lmstudio: return "http://localhost:1234"
default: return "https://your-endpoint.com/v1/chat/completions"
}
}

/// Example model identifiers shown under the model field.
var modelHint: String {
switch self {
case .ollama: return "Vision models work best: qwen3-vl, gemma4, llama3.2-vision. Pull one with `ollama pull qwen3-vl`."
case .lmstudio: return "Load a vision-capable model in LM Studio (Qwen3-VL, Gemma 4) and start the local server."
case .custom: return "e.g. gpt-4o, claude-sonnet-4, gemini-2.5-flash"
default: return "Leave empty for the default. e.g. \(defaultModel)"
}
}

/// Link to get an API key, or to install the local server.
var signupURL: URL? {
switch self {
case .superpaste: return nil
case .anthropic: return URL(string: "https://console.anthropic.com/settings/keys")
case .openai: return URL(string: "https://platform.openai.com/api-keys")
case .openrouter: return URL(string: "https://openrouter.ai/keys")
case .ollama: return URL(string: "https://ollama.com/download")
case .lmstudio: return URL(string: "https://lmstudio.ai")
case .custom: return nil
}
}

/// Label for the `signupURL` link.
var signupLabel: String {
switch self {
case .ollama: return "Install Ollama"
case .lmstudio: return "Install LM Studio"
default: return "Get a \(displayName) API key"
}
}
}

/// Persisted user selection for which provider + model to use.
struct LLMProviderConfig: Codable, Equatable {
var provider: LLMProviderID
var model: String
var customEndpoint: String // only for .custom
/// Endpoint override. Used by `.custom` (required) and by local providers
/// (optional; empty means the server's default port on localhost).
var customEndpoint: String

static let `default` = LLMProviderConfig(
provider: .superpaste,
model: LLMProviderID.superpaste.defaultModel,
customEndpoint: ""
)

/// The chat-completions URL this configuration will POST to, or nil when
/// the provider needs an endpoint the user hasn't supplied yet.
var resolvedEndpoint: String? {
if provider.supportsCustomEndpoint {
let trimmed = customEndpoint.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return provider.isLocal ? provider.endpointURL : nil
}
return LocalEndpoint.chatCompletionsURL(from: trimmed)
}
return provider.endpointURL.isEmpty ? nil : provider.endpointURL
}

/// True when the selected provider has everything it needs to serve a
/// paste without the hosted backend: a bring-your-own provider with an
/// endpoint and, where the provider demands one, a saved API key.
var isOwnProviderReady: Bool {
guard provider.isBringYourOwnKey else { return false }
guard resolvedEndpoint != nil else { return false }
if provider.requiresAPIKey {
return UserCredentialStore.apiKey(for: provider) != nil
}
return true
}
}

// MARK: - Credential storage
Expand Down Expand Up @@ -116,20 +212,23 @@ enum UserCredentialStore {
keychain(for: provider).delete()
}

/// True when ANY BYO provider has a key saved. Used to know if we should
/// skip trial enforcement in the state machine.
/// True when ANY BYO provider has a key saved.
static var anyKeyActive: Bool {
for provider in LLMProviderID.allCases where provider.isBringYourOwnKey {
if apiKey(for: provider) != nil { return true }
}
return false
}

/// The provider that currently has an active API key, or nil.
/// The provider that will actually serve the next paste outside the hosted
/// backend, or nil when the hosted backend (trial or license) is in use.
/// Keyless local servers count: a saved key is not what makes them active.
static func activeProvider(for config: LLMProviderConfig) -> LLMProviderID? {
config.isOwnProviderReady ? config.provider : nil
}

/// Convenience over the persisted configuration.
static var activeProvider: LLMProviderID? {
for provider in LLMProviderID.allCases where provider.isBringYourOwnKey {
if apiKey(for: provider) != nil { return provider }
}
return nil
activeProvider(for: LLMService.currentConfig())
}
}
}
4 changes: 3 additions & 1 deletion SuperPaste/Sources/Models/PasteFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ struct PasteFailure: Equatable {
case .serverError, .invalidResponse, .emptyResponse, .truncatedResponse:
(code, recovery) = (.providerResponse, .retry)
case .timeout: (code, recovery) = (.timeout, .retry)
case .invalidAPIKey, .providerNotConfigured:
case .invalidAPIKey, .providerNotConfigured, .modelNotFound, .noModelsAvailable:
(code, recovery) = (.providerConfiguration, .openSettings)
case .localServerUnreachable:
(code, recovery) = (.network, .retry)
case .imageEncodingFailed, .unclearContext:
(code, recovery) = (.capture, nil)
}
Expand Down
Loading
Loading