diff --git a/README.md b/README.md index 5801569..eb55a83 100644 --- a/README.md +++ b/README.md @@ -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). --- @@ -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. diff --git a/SuperPaste/Sources/Models/AppState.swift b/SuperPaste/Sources/Models/AppState.swift index 7555ba4..c851a52 100644 --- a/SuperPaste/Sources/Models/AppState.swift +++ b/SuperPaste/Sources/Models/AppState.swift @@ -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() @@ -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 diff --git a/SuperPaste/Sources/Models/LLMProvider.swift b/SuperPaste/Sources/Models/LLMProvider.swift index 20a197e..e1e195b 100644 --- a/SuperPaste/Sources/Models/LLMProvider.swift +++ b/SuperPaste/Sources/Models/LLMProvider.swift @@ -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 } @@ -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)" } } @@ -26,7 +30,9 @@ 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." } } @@ -34,14 +40,43 @@ enum LLMProviderID: String, CaseIterable, Identifiable, Codable { 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 "" } } @@ -49,40 +84,101 @@ enum LLMProviderID: String, CaseIterable, Identifiable, Codable { 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 @@ -116,8 +212,7 @@ 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 } @@ -125,11 +220,15 @@ enum UserCredentialStore { 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()) } -} \ No newline at end of file +} diff --git a/SuperPaste/Sources/Models/PasteFailure.swift b/SuperPaste/Sources/Models/PasteFailure.swift index b1f85b3..bd7c730 100644 --- a/SuperPaste/Sources/Models/PasteFailure.swift +++ b/SuperPaste/Sources/Models/PasteFailure.swift @@ -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) } diff --git a/SuperPaste/Sources/Services/LLMService.swift b/SuperPaste/Sources/Services/LLMService.swift index a8cf62d..0ad92df 100644 --- a/SuperPaste/Sources/Services/LLMService.swift +++ b/SuperPaste/Sources/Services/LLMService.swift @@ -23,6 +23,13 @@ final class LLMService { case truncatedResponse case invalidAPIKey case providerNotConfigured + /// A local or custom server did not answer at all (connection refused). + /// Carries the host:port so the message can say where we looked. + case localServerUnreachable(String) + /// The server answered but does not have the requested model installed. + case modelNotFound(String) + /// A local or custom server reports no installed models at all. + case noModelsAvailable var errorDescription: String? { switch self { @@ -54,6 +61,12 @@ final class LLMService { return "The API key was rejected." case .providerNotConfigured: return "No provider is configured. Add an API key in Settings." + case .localServerUnreachable(let host): + return "No server is answering at \(host)." + case .modelNotFound(let model): + return "Model \(model) is not available on the server." + case .noModelsAvailable: + return "The server has no models installed." } } @@ -85,6 +98,12 @@ final class LLMService { return "Your API key was rejected \u{2014} check it in Settings." case .providerNotConfigured: return "No API key has been saved for this provider \u{2014} add one in Settings." + case .localServerUnreachable(let host): + return "Nothing is running at \(host). Start your local model server, then try again." + case .modelNotFound(let model): + return "Your server doesn't have \(model). Pull it first, or pick an installed model in Settings." + case .noModelsAvailable: + return "Your local server has no models yet. Pull a vision model (for example qwen3-vl) and try again." } } } @@ -171,23 +190,55 @@ final class LLMService { context: context, base64Image: base64Image ) - case .custom: - guard let key = UserCredentialStore.apiKey(for: .custom) else { - throw LLMError.providerNotConfigured - } - guard !config.customEndpoint.isEmpty else { + case .ollama, .lmstudio, .custom: + // Endpoint-configured providers. Local servers need no key; a + // custom endpoint may have one saved, which is sent when present. + guard let endpoint = config.resolvedEndpoint else { throw LLMError.providerNotConfigured } + let apiKey = UserCredentialStore.apiKey(for: config.provider) + let model = try await resolveModel(for: config, endpoint: endpoint, apiKey: apiKey) return try await processWithOpenAICompatible( - baseURL: config.customEndpoint, - apiKey: key, - model: config.model.isEmpty ? "gpt-4o" : config.model, + baseURL: endpoint, + apiKey: apiKey, + model: model, context: context, base64Image: base64Image ) } } + /// The model to request from an endpoint-configured provider. When the + /// user left the field empty, ask the server what it has and prefer a + /// vision-capable model, so a fresh Ollama install works with zero typing. + private func resolveModel( + for config: LLMProviderConfig, + endpoint: String, + apiKey: String? + ) async throws -> String { + let trimmed = config.model.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + guard config.provider.supportsModelDiscovery else { return "gpt-4o" } + + let models: [LocalModelServer.Model] + do { + models = try await LocalModelServer.listModels(chatCompletionsURL: endpoint, apiKey: apiKey) + } catch LocalModelServer.ListError.unreachable where config.provider.isLocal { + throw LLMError.localServerUnreachable(LocalEndpoint.displayHost(endpoint)) + } catch LocalModelServer.ListError.unauthorized { + throw LLMError.invalidAPIKey + } catch { + // Custom gateways without a model list (or briefly unreachable ones) + // still deserve a sensible default; the chat call reports its own errors. + if config.provider == .custom { return "gpt-4o" } + throw LLMError.noModelsAvailable + } + guard let picked = LocalModelServer.pickDefaultModel(from: models.map(\.id)) else { + throw LLMError.noModelsAvailable + } + return picked + } + // MARK: - SuperPaste hosted backend private struct ProxyRequest: Encodable { @@ -440,7 +491,7 @@ final class LLMService { private func processWithOpenAICompatible( baseURL: String, - apiKey: String, + apiKey: String?, model: String, context: ScreenCaptureService.CapturedContext, base64Image: String @@ -478,7 +529,11 @@ final class LLMService { var urlRequest = URLRequest(url: url) urlRequest.httpMethod = "POST" urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + // Local servers (Ollama, LM Studio) accept requests with no key at all, + // so the header is only sent when the user saved one. + if let apiKey, !apiKey.isEmpty { + urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } // Attribution — lets every provider identify SuperPaste traffic. Self.applyAttributionHeaders(&urlRequest) @@ -491,7 +546,20 @@ final class LLMService { urlRequest.timeoutInterval = APIConfig.timeoutInterval urlRequest.httpBody = try JSONEncoder().encode(request) - let (data, response) = try await performRequest(urlRequest) + let data: Data + let response: URLResponse + do { + (data, response) = try await performRequest(urlRequest) + } catch LLMError.networkError(let underlying) { + // A refused connection on a local endpoint means the server isn't + // running, which deserves a more useful message than "network error". + if let urlError = underlying as? URLError, + urlError.code == .cannotConnectToHost || urlError.code == .cannotFindHost, + LocalEndpoint.isLoopback(baseURL) { + throw LLMError.localServerUnreachable(LocalEndpoint.displayHost(baseURL)) + } + throw LLMError.networkError(underlying) + } guard let httpResponse = response as? HTTPURLResponse else { throw LLMError.invalidResponse @@ -500,7 +568,17 @@ final class LLMService { if httpResponse.statusCode == 401 { throw LLMError.invalidAPIKey } + if httpResponse.statusCode == 404 { + // Ollama answers 404 with "model 'x' not found, try pulling it first". + // LM Studio uses the same status for an unloaded model. + throw LLMError.modelNotFound(model) + } guard (200...299).contains(httpResponse.statusCode) else { + if let apiError = try? JSONDecoder().decode(OpenAIResponse.self, from: data).error, + let message = apiError.message?.lowercased(), + message.contains("not found") || message.contains("no model") || message.contains("not loaded") { + throw LLMError.modelNotFound(model) + } throw LLMError.serverError(httpResponse.statusCode) } diff --git a/SuperPaste/Sources/Services/LocalModelServer.swift b/SuperPaste/Sources/Services/LocalModelServer.swift new file mode 100644 index 0000000..20a82dc --- /dev/null +++ b/SuperPaste/Sources/Services/LocalModelServer.swift @@ -0,0 +1,215 @@ +import Foundation + +/// Endpoint arithmetic for OpenAI-compatible servers. Users paste all kinds +/// of things into the endpoint field (`localhost:11434`, `http://host/v1`, a +/// full chat-completions URL); every form is accepted and normalized here. +enum LocalEndpoint { + private static let chatPath = "/v1/chat/completions" + private static let modelsPath = "/v1/models" + + /// Normalizes whatever the user typed into a chat-completions URL. + /// + /// - `localhost:11434` becomes `http://localhost:11434/v1/chat/completions` + /// - `http://localhost:1234/v1` becomes `http://localhost:1234/v1/chat/completions` + /// - a full `.../v1/chat/completions` URL is returned unchanged (minus trailing slash) + /// - other paths (reverse proxies, custom mounts) get `/chat/completions` appended + static func chatCompletionsURL(from input: String) -> String { + var value = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return "" } + if !value.contains("://") { + value = "http://" + value + } + while value.hasSuffix("/") { value.removeLast() } + + guard var components = URLComponents(string: value) else { return value } + let path = components.path + if path.isEmpty { + components.path = chatPath + } else if !path.hasSuffix("/chat/completions") { + // `/v1`, or a reverse-proxy mount like `/ollama/v1`: append the leaf. + components.path = path + "/chat/completions" + } + return components.string ?? value + } + + /// Derives the `GET /v1/models` URL that sits beside a chat-completions URL. + /// `http://localhost:11434/v1/chat/completions` becomes `http://localhost:11434/v1/models`. + static func modelsURL(fromChatCompletionsURL chatURL: String) -> URL? { + let normalized = chatCompletionsURL(from: chatURL) + guard var components = URLComponents(string: normalized) else { return nil } + var path = components.path + if path.hasSuffix("/chat/completions") { + path = String(path.dropLast("/chat/completions".count)) + } + if path.isEmpty || path == "/" { + path = modelsPath + } else { + path += "/models" + } + components.path = path + components.query = nil + return components.url + } + + /// The `host:port` the request will reach, for display in Settings and + /// Diagnostics (never the full URL, which could carry a token in a query). + static func displayHost(_ endpoint: String) -> String { + guard let components = URLComponents(string: chatCompletionsURL(from: endpoint)), + let host = components.host else { + return endpoint + } + if let port = components.port { + return "\(host):\(port)" + } + return host + } + + /// True when the endpoint resolves to this machine, which is what lets the + /// UI promise that screenshots never leave the Mac. + static func isLoopback(_ endpoint: String) -> Bool { + guard let components = URLComponents(string: chatCompletionsURL(from: endpoint)), + let host = components.host?.lowercased() else { + return false + } + if host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" || host == "0.0.0.0" { + return true + } + if host.hasPrefix("127.") { return true } + if host.hasSuffix(".localhost") { return true } + return false + } +} + +/// Heuristics about model identifiers reported by local servers. Servers +/// don't expose capability metadata through `/v1/models`, so the name is the +/// only signal available before a request is spent. +enum ModelCapabilityHint { + private static let visionMarkers: [String] = [ + "vl", "vision", "llava", "bakllava", "moondream", "minicpm-v", + "pixtral", "gemma3", "gemma-3", "gemma4", "gemma-4", "qwen2.5vl", + "qwen2-vl", "qwen3-vl", "granite3.2-vision", "mistral-small3", + "mistral-small-3", "llama3.2-vision", "llama-3.2-11b-vision", + "llama-3.2-90b-vision", "phi-4-multimodal", "phi4-multimodal", + "internvl", "idefics", "smolvlm", "paligemma", "florence", + ] + + /// Whether a model id looks like it accepts images. False negatives are + /// fine (the user can still pick the model); the hint only orders and + /// labels the list. + static func likelySupportsVision(_ modelID: String) -> Bool { + let lower = modelID.lowercased() + for marker in visionMarkers { + if marker == "vl" { + // "vl" is short enough to appear inside unrelated words, so + // require it to stand as its own token: qwen3-vl, qwen2.5vl:7b. + if lower.range(of: #"(^|[^a-z])vl([^a-z]|$)"#, options: .regularExpression) != nil + || lower.range(of: #"\dvl([^a-z]|$)"#, options: .regularExpression) != nil { + return true + } + continue + } + if lower.contains(marker) { return true } + } + return false + } + + /// Vision-capable models first, each group alphabetical, duplicates removed. + static func sortedForPicker(_ ids: [String]) -> [String] { + var seen = Set() + let unique = ids.filter { seen.insert($0).inserted } + let vision = unique.filter(likelySupportsVision).sorted() + let other = unique.filter { !likelySupportsVision($0) }.sorted() + return vision + other + } +} + +/// Talks to a local (or any OpenAI-compatible) server's model list. +enum LocalModelServer { + struct Model: Equatable, Identifiable { + let id: String + var likelySupportsVision: Bool { ModelCapabilityHint.likelySupportsVision(id) } + } + + enum ListError: LocalizedError, Equatable { + case invalidEndpoint + case unreachable + case unauthorized + case serverError(Int) + case invalidResponse + + var errorDescription: String? { + switch self { + case .invalidEndpoint: return "That endpoint isn't a valid URL." + case .unreachable: return "Nothing is answering at that address. Is the server running?" + case .unauthorized: return "The server wants an API key." + case .serverError(let code): return "The server answered with status \(code)." + case .invalidResponse: return "The server answered, but not with a model list." + } + } + } + + /// Parses an OpenAI-style `{ "data": [ { "id": "..." } ] }` body. Also + /// accepts Ollama's native `/api/tags` shape (`{ "models": [ { "name": "..." } ] }`) + /// in case a reverse proxy maps that path, and a bare array of ids. + static func parseModelList(_ data: Data) -> [Model]? { + guard let json = try? JSONSerialization.jsonObject(with: data) else { return nil } + + if let dict = json as? [String: Any] { + if let entries = dict["data"] as? [[String: Any]] { + return entries.compactMap { ($0["id"] as? String).map(Model.init(id:)) } + } + if let entries = dict["models"] as? [[String: Any]] { + return entries.compactMap { entry in + ((entry["name"] as? String) ?? (entry["model"] as? String)).map(Model.init(id:)) + } + } + return nil + } + if let ids = json as? [String] { + return ids.map(Model.init(id:)) + } + if let entries = json as? [[String: Any]] { + return entries.compactMap { ($0["id"] as? String).map(Model.init(id:)) } + } + return nil + } + + /// Chooses the model to use when the user left the field empty: the first + /// vision-capable model, otherwise the first model the server reports. + static func pickDefaultModel(from ids: [String]) -> String? { + let ordered = ModelCapabilityHint.sortedForPicker(ids) + return ordered.first + } + + /// Fetches the model list for a chat-completions endpoint. Pass an empty + /// key for servers that don't need one (Ollama, LM Studio by default). + static func listModels( + chatCompletionsURL: String, + apiKey: String?, + timeout: TimeInterval = 5 + ) async throws -> [Model] { + guard let url = LocalEndpoint.modelsURL(fromChatCompletionsURL: chatCompletionsURL) else { + throw ListError.invalidEndpoint + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue(APIConfig.userAgent, forHTTPHeaderField: "User-Agent") + if let apiKey, !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw ListError.unreachable + } + guard let http = response as? HTTPURLResponse else { throw ListError.invalidResponse } + if http.statusCode == 401 || http.statusCode == 403 { throw ListError.unauthorized } + guard (200...299).contains(http.statusCode) else { throw ListError.serverError(http.statusCode) } + guard let models = parseModelList(data) else { throw ListError.invalidResponse } + return models + } +} diff --git a/SuperPaste/Sources/Views/MainWindow/TrialExpiredView.swift b/SuperPaste/Sources/Views/MainWindow/TrialExpiredView.swift index 7d1b11c..a3a067e 100644 --- a/SuperPaste/Sources/Views/MainWindow/TrialExpiredView.swift +++ b/SuperPaste/Sources/Views/MainWindow/TrialExpiredView.swift @@ -74,7 +74,7 @@ struct TrialExpiredView: View { .buttonStyle(.borderedProminent) .controlSize(.large) - Text("Prefer not to subscribe? Add your own API key in Settings and SuperPaste stays free forever. Choose from Anthropic, OpenAI, OpenRouter, or a custom endpoint.") + Text("Prefer not to subscribe? SuperPaste stays free forever with your own setup in Settings: an Anthropic, OpenAI, or OpenRouter key, or a model running on this Mac through Ollama or LM Studio (no key, no account).") .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/SuperPaste/Sources/Views/MenuBar/MenuBarView.swift b/SuperPaste/Sources/Views/MenuBar/MenuBarView.swift index 9a01f6d..b88d754 100644 --- a/SuperPaste/Sources/Views/MenuBar/MenuBarView.swift +++ b/SuperPaste/Sources/Views/MenuBar/MenuBarView.swift @@ -66,7 +66,7 @@ struct MenuBarView: View { Text("Paused — hotkey released") } else if appState.isProcessing { Text("Working…") - } else if let provider = UserCredentialStore.activeProvider { + } else if let provider = UserCredentialStore.activeProvider(for: appState.providerConfig) { Text("Ready (\(provider.displayName)) — \(HotkeyPreset.current.shortName)") } else if appState.isLicensed { Text("Ready — \(HotkeyPreset.current.shortName)") diff --git a/SuperPaste/Sources/Views/Settings/DiagnosticsPage.swift b/SuperPaste/Sources/Views/Settings/DiagnosticsPage.swift index 3b054b6..21399b0 100644 --- a/SuperPaste/Sources/Views/Settings/DiagnosticsPage.swift +++ b/SuperPaste/Sources/Views/Settings/DiagnosticsPage.swift @@ -22,6 +22,10 @@ struct DiagnosticsPage: View { row("Competing SuperPaste", appState.hasCompetingInstance ? "yes — quit one copy" : "no") row("Paused", appState.isPaused ? "yes" : "no") row("Provider", appState.providerConfig.provider.displayName) + if appState.providerConfig.provider.supportsCustomEndpoint { + row("Endpoint host", appState.providerConfig.resolvedEndpoint.map(LocalEndpoint.displayHost) ?? "not set") + row("Model", appState.providerConfig.model.isEmpty ? "auto (first vision model on the server)" : appState.providerConfig.model) + } row("Last failure code", appState.hudState.lastFailure?.code.rawValue ?? "none") } .padding(24) diff --git a/SuperPaste/Sources/Views/Settings/SettingsPage.swift b/SuperPaste/Sources/Views/Settings/SettingsPage.swift index 58cb8f1..ddab906 100644 --- a/SuperPaste/Sources/Views/Settings/SettingsPage.swift +++ b/SuperPaste/Sources/Views/Settings/SettingsPage.swift @@ -55,12 +55,23 @@ struct SettingsPage: View { // MARK: - AI Provider Section + /// Result of the last `/v1/models` probe against an endpoint-configured provider. + private enum ConnectionStatus: Equatable { + case idle + case checking + case connected(modelCount: Int) + case failed(String) + } + + @State private var discoveredModels: [LocalModelServer.Model] = [] + @State private var connectionStatus: ConnectionStatus = .idle + private var aiProviderSection: some View { VStack(alignment: .leading, spacing: 12) { Text("AI Provider") .font(.headline) - Text("Choose which AI service powers your pastes. You can use the hosted SuperPaste backend, your own API key from Anthropic, OpenAI, or OpenRouter, or a custom OpenAI-compatible endpoint.") + Text("Choose which AI service powers your pastes: the hosted SuperPaste backend, your own API key from Anthropic, OpenAI, or OpenRouter, a model running on this Mac through Ollama or LM Studio, or any custom OpenAI-compatible endpoint.") .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -82,35 +93,14 @@ struct SettingsPage: View { .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - // Model field (hidden for superpaste) - if selectedProvider.supportsCustomModel { - VStack(alignment: .leading, spacing: 4) { - Text("Model") - .font(.subheadline.weight(.medium)) - TextField("Model identifier", text: $customModelInput) - .textFieldStyle(.roundedBorder) - .onChange(of: customModelInput) { _, newValue in - saveProviderChange(provider: selectedProvider) - } - Text(selectedProvider == .custom - ? "e.g. gpt-4o, claude-sonnet-4, gemini-2.5-flash" - : "Leave empty for the default. e.g. \(selectedProvider.defaultModel)") - .font(.caption) - .foregroundColor(.secondary) - } + // Endpoint (local servers and custom) + if selectedProvider.supportsCustomEndpoint { + endpointField } - // Custom endpoint (only for "custom") - if selectedProvider == .custom { - VStack(alignment: .leading, spacing: 4) { - Text("API Endpoint") - .font(.subheadline.weight(.medium)) - TextField("https://your-endpoint.com/v1/chat/completions", text: $customEndpointInput) - .textFieldStyle(.roundedBorder) - .onChange(of: customEndpointInput) { _, newValue in - saveProviderChange(provider: selectedProvider) - } - } + // Model field (hidden for superpaste) + if selectedProvider.supportsCustomModel { + modelField } // API key section per provider @@ -121,6 +111,181 @@ struct SettingsPage: View { RoundedRectangle(cornerRadius: 8) .fill(Color(nsColor: .controlBackgroundColor)) ) + .task(id: discoveryKey) { + await refreshModels(debounce: true) + } + } + + /// Changes to either value re-run model discovery. + private var discoveryKey: String { + "\(selectedProvider.rawValue)|\(customEndpointInput)" + } + + private var endpointField: some View { + VStack(alignment: .leading, spacing: 4) { + Text(selectedProvider.isLocal ? "Server address" : "API Endpoint") + .font(.subheadline.weight(.medium)) + TextField(selectedProvider.endpointPlaceholder, text: $customEndpointInput) + .textFieldStyle(.roundedBorder) + .onChange(of: customEndpointInput) { _, _ in + saveProviderChange(provider: selectedProvider) + } + Text(endpointHelpText) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var endpointHelpText: String { + guard selectedProvider.isLocal else { + return "Paste the base URL (ending in /v1) or the full chat-completions URL. Both work." + } + let typed = customEndpointInput.trimmingCharacters(in: .whitespacesAndNewlines) + if typed.isEmpty { + return "Leave empty for the default (\(LocalEndpoint.displayHost(selectedProvider.endpointURL))). Change it only if you moved the server to another port or machine." + } + if LocalEndpoint.isLoopback(typed) { + return "Reaches \(LocalEndpoint.displayHost(typed)) on this Mac. Screenshots stay local." + } + return "Reaches \(LocalEndpoint.displayHost(typed)) over the network. Screenshots leave this Mac." + } + + private var modelField: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Model") + .font(.subheadline.weight(.medium)) + Spacer() + if selectedProvider.supportsModelDiscovery { + connectionStatusLabel + Button { + Task { await refreshModels(debounce: false) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .labelStyle(.iconOnly) + } + .buttonStyle(.plain) + .help("Ask the server which models are installed") + .disabled(connectionStatus == .checking) + } + } + + if selectedProvider.supportsModelDiscovery && !discoveredModels.isEmpty { + Picker("Installed models", selection: $customModelInput) { + Text(selectedProvider.isLocal ? "Auto (first vision model on the server)" : "Auto (first model on the server)") + .tag("") + ForEach(ModelCapabilityHint.sortedForPicker(discoveredModels.map(\.id)), id: \.self) { id in + Text(ModelCapabilityHint.likelySupportsVision(id) ? "\(id) (vision)" : id) + .tag(id) + } + if !customModelInput.isEmpty && !discoveredModels.contains(where: { $0.id == customModelInput }) { + Text("\(customModelInput) (not installed)").tag(customModelInput) + } + } + .pickerStyle(.menu) + .labelsHidden() + .onChange(of: customModelInput) { _, _ in + saveProviderChange(provider: selectedProvider) + } + } else { + TextField(selectedProvider.supportsModelDiscovery ? "Leave empty to use the first installed model" : "Model identifier", text: $customModelInput) + .textFieldStyle(.roundedBorder) + .onChange(of: customModelInput) { _, _ in + saveProviderChange(provider: selectedProvider) + } + } + + Text(selectedProvider.modelHint) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if selectedProvider.supportsModelDiscovery, + !customModelInput.isEmpty, + !discoveredModels.isEmpty, + !ModelCapabilityHint.likelySupportsVision(customModelInput) { + Label("This model may not read screenshots. Vision models are marked in the list.", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundColor(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private var connectionStatusLabel: some View { + switch connectionStatus { + case .idle: + EmptyView() + case .checking: + ProgressView() + .scaleEffect(0.5) + .frame(width: 14, height: 14) + case .connected(let count): + Label(count == 1 ? "Connected, 1 model" : "Connected, \(count) models", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundColor(.green) + case .failed(let message): + Label(message, systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.orange) + .lineLimit(1) + .help(message) + } + } + + /// Probes `/v1/models` on the configured endpoint. Runs automatically when + /// the provider or endpoint changes (debounced so typing doesn't hammer the + /// server) and on demand from the refresh button. + @MainActor + private func refreshModels(debounce: Bool) async { + guard selectedProvider.supportsModelDiscovery else { + connectionStatus = .idle + discoveredModels = [] + return + } + if debounce { + try? await Task.sleep(for: .milliseconds(400)) + if Task.isCancelled { return } + } + let config = LLMProviderConfig( + provider: selectedProvider, + model: customModelInput, + customEndpoint: customEndpointInput + ) + guard let endpoint = config.resolvedEndpoint else { + connectionStatus = .idle + discoveredModels = [] + return + } + connectionStatus = .checking + do { + let models = try await LocalModelServer.listModels( + chatCompletionsURL: endpoint, + apiKey: UserCredentialStore.apiKey(for: selectedProvider) + ) + if Task.isCancelled { return } + discoveredModels = models + connectionStatus = models.isEmpty + ? .failed(selectedProvider == .ollama ? "Connected, no models yet. Run: ollama pull qwen3-vl" : "Connected, no models loaded") + : .connected(modelCount: models.count) + } catch let error as LocalModelServer.ListError { + if Task.isCancelled { return } + discoveredModels = [] + switch error { + case .unreachable: + connectionStatus = .failed(selectedProvider.isLocal + ? "\(selectedProvider == .ollama ? "Ollama" : "LM Studio") isn't running at \(LocalEndpoint.displayHost(endpoint))" + : "Nothing answering at \(LocalEndpoint.displayHost(endpoint))") + default: + connectionStatus = .failed(error.errorDescription ?? "Couldn't reach the server") + } + } catch { + if Task.isCancelled { return } + discoveredModels = [] + connectionStatus = .failed("Couldn't reach the server") + } } @ViewBuilder @@ -132,7 +297,9 @@ struct SettingsPage: View { HStack(spacing: 8) { Image(systemName: "key.fill") .foregroundColor(.green) - Text("Using your \(provider.displayName) API key — requests go directly to \(provider.displayName), not through SuperPaste's servers.") + Text(provider.requiresAPIKey + ? "Using your \(provider.displayName) API key: requests go directly to \(provider.displayName), not through SuperPaste's servers." + : "Sending your saved key to this server with every request.") .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) Spacer() @@ -146,7 +313,7 @@ struct SettingsPage: View { } .font(.caption) .padding(.vertical, 4) - } else { + } else if provider.requiresAPIKey { Text("SuperPaste is free forever with your own key: screenshots go directly to \(provider.displayName). You pay them for what you use (typically well under a cent per paste).") .font(.caption) .foregroundColor(.secondary) @@ -161,12 +328,34 @@ struct SettingsPage: View { } .disabled(apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - - if let url = provider.signupURL { - Link("Get a \(provider.displayName) API key \u{2192}", - destination: url) - .font(.caption) + } else { + DisclosureGroup("API key (optional)") { + VStack(alignment: .leading, spacing: 6) { + Text(provider.isLocal + ? "\(provider.displayName) doesn't need a key. Add one only if you turned on authentication in the server's settings." + : "Leave this empty for servers that don't check credentials. Saved keys stay in the macOS Keychain.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + SecureField("API key", text: $apiKeyInput) + .textFieldStyle(.roundedBorder) + Button("Save") { + appState.setAPIKey(apiKeyInput, for: provider) + apiKeyInput = "" + Task { await refreshModels(debounce: false) } + } + .disabled(apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(.top, 4) } + .font(.caption) + } + + if let url = provider.signupURL, !hasKey || provider.isLocal { + Link("\(provider.signupLabel) \u{2192}", destination: url) + .font(.caption) } } } @@ -176,7 +365,7 @@ struct SettingsPage: View { let config = LLMProviderConfig( provider: provider, model: provider.supportsCustomModel ? customModelInput : provider.defaultModel, - customEndpoint: provider == .custom ? customEndpointInput : "" + customEndpoint: provider.supportsCustomEndpoint ? customEndpointInput : "" ) appState.setProviderConfig(config) } diff --git a/SuperPaste/Tests/SuperPasteTests/LLMProviderTests.swift b/SuperPaste/Tests/SuperPasteTests/LLMProviderTests.swift new file mode 100644 index 0000000..26176f6 --- /dev/null +++ b/SuperPaste/Tests/SuperPasteTests/LLMProviderTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import SuperPaste + +struct LLMProviderTests { + + @Test func allProvidersHaveUniqueRawValuesAndNames() { + let raw = LLMProviderID.allCases.map(\.rawValue) + #expect(Set(raw).count == raw.count) + let names = LLMProviderID.allCases.map(\.displayName) + #expect(Set(names).count == names.count) + for provider in LLMProviderID.allCases { + #expect(!provider.description.isEmpty) + } + } + + @Test func localProvidersNeedNoKey() { + #expect(LLMProviderID.ollama.isLocal) + #expect(LLMProviderID.lmstudio.isLocal) + #expect(!LLMProviderID.ollama.requiresAPIKey) + #expect(!LLMProviderID.lmstudio.requiresAPIKey) + #expect(!LLMProviderID.custom.requiresAPIKey) + #expect(LLMProviderID.anthropic.requiresAPIKey) + #expect(LLMProviderID.openai.requiresAPIKey) + #expect(LLMProviderID.openrouter.requiresAPIKey) + } + + @Test func localProvidersAreBringYourOwnButNotCustom() { + for provider in [LLMProviderID.ollama, .lmstudio] { + #expect(provider.isBringYourOwnKey) + #expect(provider.supportsCustomEndpoint) + #expect(provider.supportsModelDiscovery) + #expect(provider.supportsCustomModel) + #expect(provider.defaultModel.isEmpty) + } + #expect(!LLMProviderID.anthropic.supportsCustomEndpoint) + #expect(!LLMProviderID.superpaste.isBringYourOwnKey) + } + + @Test func localDefaultEndpointsAreTheServersDefaultPorts() { + #expect(LLMProviderID.ollama.endpointURL == "http://localhost:11434/v1/chat/completions") + #expect(LLMProviderID.lmstudio.endpointURL == "http://localhost:1234/v1/chat/completions") + for provider in LLMProviderID.allCases where !provider.endpointURL.isEmpty { + #expect(URL(string: provider.endpointURL) != nil) + } + } + + @Test func installLinksExistForLocalProviders() { + #expect(LLMProviderID.ollama.signupURL?.host == "ollama.com") + #expect(LLMProviderID.lmstudio.signupURL?.host == "lmstudio.ai") + #expect(LLMProviderID.ollama.signupLabel == "Install Ollama") + #expect(LLMProviderID.anthropic.signupLabel.contains("API key")) + } + + // MARK: - LLMProviderConfig.resolvedEndpoint + + @Test func localProviderFallsBackToDefaultEndpoint() { + let config = LLMProviderConfig(provider: .ollama, model: "", customEndpoint: "") + #expect(config.resolvedEndpoint == LLMProviderID.ollama.endpointURL) + let spaced = LLMProviderConfig(provider: .lmstudio, model: "", customEndpoint: " ") + #expect(spaced.resolvedEndpoint == LLMProviderID.lmstudio.endpointURL) + } + + @Test func localProviderHonorsEndpointOverride() { + let config = LLMProviderConfig(provider: .ollama, model: "", customEndpoint: "192.168.1.20:11434") + #expect(config.resolvedEndpoint == "http://192.168.1.20:11434/v1/chat/completions") + } + + @Test func customProviderRequiresAnEndpoint() { + let empty = LLMProviderConfig(provider: .custom, model: "", customEndpoint: "") + #expect(empty.resolvedEndpoint == nil) + #expect(!empty.isOwnProviderReady) + let set = LLMProviderConfig(provider: .custom, model: "", customEndpoint: "https://gateway.example.com/v1") + #expect(set.resolvedEndpoint == "https://gateway.example.com/v1/chat/completions") + } + + @Test func hostedProviderIsNeverOwnProvider() { + let config = LLMProviderConfig.default + #expect(config.resolvedEndpoint == nil) + #expect(!config.isOwnProviderReady) + #expect(UserCredentialStore.activeProvider(for: config) == nil) + } + + @Test func keylessLocalProviderCountsAsReady() { + // This is what keeps a fully local user out of the trial-expired state: + // no Keychain entry exists, and none is needed. + let ollama = LLMProviderConfig(provider: .ollama, model: "", customEndpoint: "") + #expect(ollama.isOwnProviderReady) + #expect(UserCredentialStore.activeProvider(for: ollama) == .ollama) + let lmstudio = LLMProviderConfig(provider: .lmstudio, model: "qwen3-vl", customEndpoint: "localhost:1234") + #expect(lmstudio.isOwnProviderReady) + #expect(UserCredentialStore.activeProvider(for: lmstudio) == .lmstudio) + } + + @Test func keylessCustomEndpointCountsAsReady() { + let config = LLMProviderConfig(provider: .custom, model: "", customEndpoint: "http://localhost:8080/v1") + #expect(config.isOwnProviderReady) + } + + @Test func configRoundTripsThroughJSON() throws { + let original = LLMProviderConfig(provider: .lmstudio, model: "qwen3-vl", customEndpoint: "localhost:1234") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(LLMProviderConfig.self, from: data) + #expect(decoded == original) + } + + @Test func existingPersistedConfigStillDecodes() throws { + // Shape written by 1.1.0 installs; adding providers must not break it. + let legacy = #"{"provider":"custom","model":"gpt-4o","customEndpoint":"https://example.com/v1/chat/completions"}"# + let decoded = try JSONDecoder().decode(LLMProviderConfig.self, from: Data(legacy.utf8)) + #expect(decoded.provider == .custom) + #expect(decoded.resolvedEndpoint == "https://example.com/v1/chat/completions") + } +} diff --git a/SuperPaste/Tests/SuperPasteTests/LocalModelServerTests.swift b/SuperPaste/Tests/SuperPasteTests/LocalModelServerTests.swift new file mode 100644 index 0000000..44bcc75 --- /dev/null +++ b/SuperPaste/Tests/SuperPasteTests/LocalModelServerTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing +@testable import SuperPaste + +struct LocalEndpointTests { + + @Test func bareHostAndPortBecomesChatCompletionsURL() { + #expect(LocalEndpoint.chatCompletionsURL(from: "localhost:11434") == "http://localhost:11434/v1/chat/completions") + } + + @Test func schemeAndHostOnlyGetsV1Path() { + #expect(LocalEndpoint.chatCompletionsURL(from: "http://localhost:1234") == "http://localhost:1234/v1/chat/completions") + #expect(LocalEndpoint.chatCompletionsURL(from: "http://localhost:1234/") == "http://localhost:1234/v1/chat/completions") + } + + @Test func v1BaseGetsLeafAppended() { + #expect(LocalEndpoint.chatCompletionsURL(from: "http://localhost:11434/v1") == "http://localhost:11434/v1/chat/completions") + #expect(LocalEndpoint.chatCompletionsURL(from: "http://localhost:11434/v1/") == "http://localhost:11434/v1/chat/completions") + } + + @Test func fullChatCompletionsURLIsUnchanged() { + let full = "https://gateway.example.com/openai/v1/chat/completions" + #expect(LocalEndpoint.chatCompletionsURL(from: full) == full) + } + + @Test func reverseProxyMountGetsLeafAppended() { + #expect(LocalEndpoint.chatCompletionsURL(from: "http://nas.local:8080/ollama/v1") == "http://nas.local:8080/ollama/v1/chat/completions") + } + + @Test func whitespaceIsTrimmed() { + #expect(LocalEndpoint.chatCompletionsURL(from: " http://localhost:1234/v1 ") == "http://localhost:1234/v1/chat/completions") + #expect(LocalEndpoint.chatCompletionsURL(from: " ") == "") + } + + @Test func modelsURLSitsBesideChatCompletions() { + let url = LocalEndpoint.modelsURL(fromChatCompletionsURL: "http://localhost:11434/v1/chat/completions") + #expect(url?.absoluteString == "http://localhost:11434/v1/models") + } + + @Test func modelsURLDerivesFromAnyAcceptedInput() { + #expect(LocalEndpoint.modelsURL(fromChatCompletionsURL: "localhost:1234")?.absoluteString == "http://localhost:1234/v1/models") + #expect(LocalEndpoint.modelsURL(fromChatCompletionsURL: "https://gateway.example.com/openai/v1/chat/completions")?.absoluteString == "https://gateway.example.com/openai/v1/models") + } + + @Test func modelsURLDropsQueryString() { + let url = LocalEndpoint.modelsURL(fromChatCompletionsURL: "http://localhost:8080/v1/chat/completions?token=abc") + #expect(url?.absoluteString == "http://localhost:8080/v1/models") + } + + @Test func displayHostShowsHostAndPortOnly() { + #expect(LocalEndpoint.displayHost("http://localhost:11434/v1/chat/completions") == "localhost:11434") + #expect(LocalEndpoint.displayHost("https://api.openai.com/v1/chat/completions") == "api.openai.com") + #expect(LocalEndpoint.displayHost("localhost:1234") == "localhost:1234") + } + + @Test func loopbackDetection() { + #expect(LocalEndpoint.isLoopback("http://localhost:11434")) + #expect(LocalEndpoint.isLoopback("127.0.0.1:1234")) + #expect(LocalEndpoint.isLoopback("http://127.0.0.2:8080/v1")) + #expect(LocalEndpoint.isLoopback("http://ollama.localhost:11434")) + #expect(!LocalEndpoint.isLoopback("http://192.168.1.20:11434")) + #expect(!LocalEndpoint.isLoopback("https://api.openai.com/v1/chat/completions")) + #expect(!LocalEndpoint.isLoopback("")) + } + + @Test func defaultLocalEndpointsAreLoopback() { + #expect(LocalEndpoint.isLoopback(LLMProviderID.ollama.endpointURL)) + #expect(LocalEndpoint.isLoopback(LLMProviderID.lmstudio.endpointURL)) + } +} + +struct ModelCapabilityHintTests { + + @Test func recognizesCommonVisionModelIDs() { + for id in [ + "qwen3-vl:8b", "qwen2.5vl:7b", "llava:13b", "llama3.2-vision:11b", "gemma4:12b", + "gemma3:4b", "minicpm-v:8b", "moondream:latest", "mistral-small3.1:24b", + "lmstudio-community/Qwen3-VL-8B-Instruct-GGUF", "pixtral-12b", "granite3.2-vision:2b", + ] { + #expect(ModelCapabilityHint.likelySupportsVision(id), "expected \(id) to look vision-capable") + } + } + + @Test func doesNotFlagTextOnlyModels() { + for id in ["llama3.1:8b", "qwen3:8b", "mistral:7b", "deepseek-r1:14b", "codellama:7b", "phi4:14b", "nomic-embed-text"] { + #expect(!ModelCapabilityHint.likelySupportsVision(id), "expected \(id) to look text-only") + } + } + + @Test func shortVLMarkerNeedsAWordBoundary() { + // "vl" buried inside an unrelated word must not count. + #expect(!ModelCapabilityHint.likelySupportsVision("devlab-7b")) + #expect(ModelCapabilityHint.likelySupportsVision("qwen3-vl")) + #expect(ModelCapabilityHint.likelySupportsVision("Qwen3-VL:32b")) + } + + @Test func pickerOrderPutsVisionModelsFirstAndDedupes() { + let ordered = ModelCapabilityHint.sortedForPicker(["mistral:7b", "qwen3-vl:8b", "llama3.1:8b", "llava:13b", "qwen3-vl:8b"]) + #expect(ordered == ["llava:13b", "qwen3-vl:8b", "llama3.1:8b", "mistral:7b"]) + } +} + +struct LocalModelServerTests { + + @Test func parsesOpenAIStyleModelList() throws { + let body = #"{"object":"list","data":[{"id":"qwen3-vl:8b","object":"model","owned_by":"library"},{"id":"llama3.1:8b","object":"model"}]}"# + let models = try #require(LocalModelServer.parseModelList(Data(body.utf8))) + #expect(models.map(\.id) == ["qwen3-vl:8b", "llama3.1:8b"]) + #expect(models[0].likelySupportsVision) + #expect(!models[1].likelySupportsVision) + } + + @Test func parsesOllamaNativeTagsShape() throws { + let body = #"{"models":[{"name":"llava:13b","model":"llava:13b","size":1},{"name":"mistral:7b"}]}"# + let models = try #require(LocalModelServer.parseModelList(Data(body.utf8))) + #expect(models.map(\.id) == ["llava:13b", "mistral:7b"]) + } + + @Test func parsesBareArrays() throws { + let ids = try #require(LocalModelServer.parseModelList(Data(#"["a","b"]"#.utf8))) + #expect(ids.map(\.id) == ["a", "b"]) + let objects = try #require(LocalModelServer.parseModelList(Data(#"[{"id":"c"}]"#.utf8))) + #expect(objects.map(\.id) == ["c"]) + } + + @Test func rejectsBodiesThatAreNotModelLists() { + #expect(LocalModelServer.parseModelList(Data("not json".utf8)) == nil) + #expect(LocalModelServer.parseModelList(Data(#"{"error":"nope"}"#.utf8)) == nil) + #expect(LocalModelServer.parseModelList(Data(#"{"data":[]}"#.utf8)) == []) + } + + @Test func defaultModelPrefersVision() { + #expect(LocalModelServer.pickDefaultModel(from: ["mistral:7b", "llama3.1:8b", "qwen3-vl:8b"]) == "qwen3-vl:8b") + #expect(LocalModelServer.pickDefaultModel(from: ["mistral:7b", "llama3.1:8b"]) == "llama3.1:8b") + #expect(LocalModelServer.pickDefaultModel(from: []) == nil) + } + + @Test func unreachableServerThrowsUnreachable() async { + // Port 9 (discard) is not listening on CI runners; the connection is refused immediately. + do { + _ = try await LocalModelServer.listModels(chatCompletionsURL: "http://127.0.0.1:9/v1/chat/completions", apiKey: nil, timeout: 2) + Issue.record("expected the request to fail") + } catch let error as LocalModelServer.ListError { + #expect(error == .unreachable) + } catch { + Issue.record("unexpected error type: \(error)") + } + } +}