diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c417c534..9c1d95da49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Save** permanently dim on a Custom provider for an OpenAI-compatible server that wants no API key. +- Model list not reloading when the API key changes, leaving the picker empty with no way to retry. +- Empty model picker, with nothing said, for a local or OpenAI-compatible server answering 200 with an unexpected shape. +- No caution when an API key is sent unencrypted over `http` to another machine. - No Base URL reaching an OpenAI-compatible server whose version segment is not `/v1`, such as Z.ai's `/v4`. (#3040) - Doubled version segment on Claude, OpenAI, xAI and Gemini when the Base URL already carried one. - **Connection successful** on a custom provider whose Base URL answered 404. diff --git a/TablePro/Core/AI/AIEndpoint.swift b/TablePro/Core/AI/AIEndpoint.swift index 3d875eede8..1b7986ca5f 100644 --- a/TablePro/Core/AI/AIEndpoint.swift +++ b/TablePro/Core/AI/AIEndpoint.swift @@ -90,6 +90,15 @@ struct AIEndpoint: Equatable, Sendable { /// Works on the percent-encoded path. `URLComponents.path` decodes `%2F`, and writing the /// decoded value back turns one segment into two, so a gateway mounted under an escaped /// separator would be sent to a different route. + /// An `Authorization: Bearer` header on a cleartext request to another machine is readable by + /// anything between here and there. Reaching a server on this machine over http is an ordinary + /// local setup, so only a remote host is worth saying anything about. + var isPlaintextToRemoteHost: Bool { + guard apiBase.scheme?.lowercased() == "http" else { return false } + guard let host = apiBase.host else { return true } + return !LoopbackHost.isLoopback(host) + } + private static func apiBasePath(for percentEncodedPath: String, style: AIEndpointStyle) -> String { let segments = percentEncodedPath.split(separator: "/").map(String.init) diff --git a/TablePro/Core/AI/AIModelListFetchGate.swift b/TablePro/Core/AI/AIModelListFetchGate.swift new file mode 100644 index 0000000000..cad7d99ed7 --- /dev/null +++ b/TablePro/Core/AI/AIModelListFetchGate.swift @@ -0,0 +1,34 @@ +// +// AIModelListFetchGate.swift +// TablePro +// + +import Foundation + +/// Whether a provider's model list can be fetched yet, and why not. +/// +/// This lives outside the settings sheet so the rule can be tested. The sheet used to clear its +/// error state in the case that blocks the fetch, which left the Model picker empty with nothing +/// said and no way to retry. +internal enum AIModelListFetchGate { + internal enum Blocker: Equatable { + case notFetchable + case missingEndpoint + case missingAPIKey + } + + internal static func blocker( + fetchesModelList: Bool, + takesEndpoint: Bool, + endpoint: String, + authStyle: AIProviderType.AuthStyle, + apiKey: String + ) -> Blocker? { + guard fetchesModelList else { return .notFetchable } + if takesEndpoint, endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .missingEndpoint + } + guard authStyle == .apiKey else { return nil } + return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .missingAPIKey : nil + } +} diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 0760dd5fe8..56b7e8b5b2 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -508,11 +508,16 @@ final class OpenAICompatibleProvider: ChatTransport { ) } - guard let json = try? JSONSerialization.jsonObject(with: data) - as? [String: Any], - let modelsArray = json["data"] as? [[String: Any]] - else { - return [] + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AIProviderError.networkError( + String(format: String(localized: "Failed to fetch models from %@"), url.absoluteString) + ) + } + + guard let modelsArray = json["data"] as? [[String: Any]] else { + throw AIProviderError.networkError( + String(format: String(localized: "Failed to fetch models from %@"), url.absoluteString) + ) } return modelsArray.compactMap { $0["id"] as? String }.sorted() @@ -543,11 +548,16 @@ final class OpenAICompatibleProvider: ChatTransport { ) } - guard let json = try? JSONSerialization.jsonObject(with: data) - as? [String: Any], - let models = json["models"] as? [[String: Any]] - else { - return [] + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AIProviderError.networkError( + String(format: String(localized: "Failed to fetch models from %@"), url.absoluteString) + ) + } + + guard let models = json["models"] as? [[String: Any]] else { + throw AIProviderError.networkError( + String(format: String(localized: "Failed to fetch models from %@"), url.absoluteString) + ) } return models.compactMap { $0["name"] as? String }.sorted() diff --git a/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift b/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift index 4f65402009..55b25f6f81 100644 --- a/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift +++ b/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift @@ -12,8 +12,6 @@ internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable { internal let username: String internal let scopeName: String - private static let loopbackHosts: Set = ["localhost", "127.0.0.1", "::1", "[::1]"] - internal init(databaseType: String, host: String, database: String, username: String, scopeName: String) { self.databaseType = databaseType.lowercased() self.host = host.trimmingCharacters(in: .whitespaces).lowercased() @@ -33,22 +31,7 @@ internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable { } internal var isLoopbackHost: Bool { - var normalized = host - while normalized.hasSuffix(".") { normalized.removeLast() } - if Self.loopbackHosts.contains(normalized) { return true } - return Self.isLoopbackIPv4(normalized) - } - - private static func isLoopbackIPv4(_ host: String) -> Bool { - let octets = host.split(separator: ".", omittingEmptySubsequences: false) - guard octets.count == 4 else { return false } - for octet in octets { - guard !octet.isEmpty, - octet.allSatisfy({ $0.isASCII && $0.isNumber }), - let value = Int(octet), value <= 255 - else { return false } - } - return Int(octets[0]) == 127 + LoopbackHost.isLoopback(host) } internal var displayDescription: String { diff --git a/TablePro/Core/Utilities/LoopbackHost.swift b/TablePro/Core/Utilities/LoopbackHost.swift new file mode 100644 index 0000000000..c54d8ab40e --- /dev/null +++ b/TablePro/Core/Utilities/LoopbackHost.swift @@ -0,0 +1,35 @@ +// +// LoopbackHost.swift +// TablePro +// + +import Foundation + +/// Whether a host name reaches this machine and nothing else. +/// +/// Several callers each grew their own copy of this, and they disagree: one counts `0.0.0.0` and +/// `localhost.localdomain`, another matches only the four exact spellings and misses the rest of +/// `127.0.0.0/8`. This is the complete one, and the answer any caller deciding whether traffic +/// leaves the machine should ask. +internal enum LoopbackHost { + private static let names: Set = ["localhost", "127.0.0.1", "::1", "[::1]"] + + internal static func isLoopback(_ host: String) -> Bool { + var normalized = host.trimmingCharacters(in: .whitespaces).lowercased() + while normalized.hasSuffix(".") { normalized.removeLast() } + if names.contains(normalized) { return true } + return isLoopbackIPv4(normalized) + } + + private static func isLoopbackIPv4(_ host: String) -> Bool { + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4 else { return false } + for octet in octets { + guard !octet.isEmpty, + octet.allSatisfy({ $0.isASCII && $0.isNumber }), + let value = Int(octet), value <= 255 + else { return false } + } + return Int(octets[0]) == 127 + } +} diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index a283424eb9..4b6ac1f4d2 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -80,6 +80,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case .llamaCpp: return .none case .mlx: return .none case .openCode: return .optionalApiKey + case .custom: return .optionalApiKey default: return .apiKey } } diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index e032bf9183..170253a6a6 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -224,6 +224,7 @@ struct AIProviderDetailSheet: View { Section { SecureField(String(localized: "API Key"), text: $apiKey) .onChange(of: apiKey) { _ in + scheduleFetchModels() testResult = nil } HStack { @@ -264,7 +265,10 @@ struct AIProviderDetailSheet: View { private var cursorAPIKeySection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) - .onChange(of: apiKey) { _ in testResult = nil } + .onChange(of: apiKey) { _ in + scheduleFetchModels() + testResult = nil + } HStack { Spacer() Button { @@ -396,7 +400,10 @@ struct AIProviderDetailSheet: View { private var xaiAPIKeySection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) - .onChange(of: apiKey) { _ in testResult = nil } + .onChange(of: apiKey) { _ in + scheduleFetchModels() + testResult = nil + } HStack { Spacer() Button { @@ -689,20 +696,51 @@ struct AIProviderDetailSheet: View { private var endpointFootnote: some View { VStack(alignment: .leading, spacing: 2) { Text("Include the version segment your server uses, such as /v1 or /v4.") + .font(.caption) + .foregroundStyle(.secondary) if let resolvedChatURL { Text(resolvedChatURL) .textSelection(.enabled) + .font(.caption) + .foregroundStyle(.secondary) + } + if resolvedEndpoint?.isPlaintextToRemoteHost == true { + Label(cleartextCaution, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + .lineLimit(2) } } - .font(.caption) - .foregroundStyle(.secondary) + } + + private var modelListBlocker: AIModelListFetchGate.Blocker? { + AIModelListFetchGate.blocker( + fetchesModelList: descriptor?.fetchesModelList == true, + takesEndpoint: descriptor?.allowsEndpointConfiguration == true, + endpoint: draft.endpoint, + authStyle: draft.type.authStyle, + apiKey: apiKey + ) + } + + /// Ollama, llama.cpp, MLX and a keyless Custom server send no authorization header at all, so + /// naming the key there would warn about something that is not happening. + private var cleartextCaution: String { + guard draft.type.authStyle.usesAPIKey, + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return String(localized: "Requests to this host are sent unencrypted over http.") + } + return String(localized: "Your API key is sent unencrypted over http to this host.") + } + + private var resolvedEndpoint: AIEndpoint? { + AIEndpoint(draft.endpoint, style: draft.type.endpointStyle) } private var resolvedChatURL: String? { let style = draft.type.endpointStyle - guard let endpoint = AIEndpoint(draft.endpoint, style: style), - let url = endpoint.chatURL(model: draft.model, style: style) - else { return nil } + guard let url = resolvedEndpoint?.chatURL(model: draft.model, style: style) else { return nil } return url.absoluteString } @@ -865,6 +903,11 @@ struct AIProviderDetailSheet: View { .controlSize(.small) } } + if modelListBlocker == .missingAPIKey { + Text("Enter an API key to load this provider's models.") + .font(.caption) + .foregroundStyle(.secondary) + } } // MARK: - Advanced @@ -972,19 +1015,22 @@ struct AIProviderDetailSheet: View { } private func fetchModels() { - guard descriptor?.fetchesModelList == true else { + switch modelListBlocker { + case .notFetchable: fetchedModels = [] modelFetchError = nil + isFetchingModels = false if draft.model.isEmpty, let first = curatedModels.first { draft.model = first.id } return - } - if draft.type.authStyle == .apiKey, - apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + case .missingEndpoint, .missingAPIKey: fetchedModels = [] modelFetchError = nil + isFetchingModels = false return + case nil: + break } let provider = AIProviderFactory.makeUncachedProvider(for: normalizedDraft, apiKey: apiKey) diff --git a/TableProTests/Core/AI/AIEndpointTests.swift b/TableProTests/Core/AI/AIEndpointTests.swift index 1584d57a19..f793b73467 100644 --- a/TableProTests/Core/AI/AIEndpointTests.swift +++ b/TableProTests/Core/AI/AIEndpointTests.swift @@ -139,6 +139,24 @@ struct AIEndpointTests { #expect(AIEndpoint("https://user@host/v1", style: .chatCompletions) == nil) } + /// An Authorization header on a cleartext request to another machine is readable in transit. + /// Reaching a server on this machine over http is an ordinary local setup. + @Test("Plaintext to a remote host is flagged, and to this machine is not") + func flagsPlaintextToARemoteHost() { + for base in ["http://gateway.internal.example.com/v1", "http://192.168.1.10:8000/v1", "http://0.0.0.0:8080/v1"] { + #expect(AIEndpoint(base, style: .chatCompletions)?.isPlaintextToRemoteHost == true, "\(base)") + } + for base in [ + "http://localhost:11434", + "http://127.0.0.1:1234/v1", + "http://127.0.0.2:8080/v1", + "https://gateway.internal.example.com/v1", + "https://api.openai.com/v1", + ] { + #expect(AIEndpoint(base, style: .chatCompletions)?.isPlaintextToRemoteHost == false, "\(base)") + } + } + @Test("Every style resolves the provider's own default endpoint") func resolvesEveryDefaultEndpoint() { for type in AIProviderType.allCases where !type.defaultEndpoint.isEmpty { diff --git a/TableProTests/Core/AI/AIModelListFetchGateTests.swift b/TableProTests/Core/AI/AIModelListFetchGateTests.swift new file mode 100644 index 0000000000..88d2d3e965 --- /dev/null +++ b/TableProTests/Core/AI/AIModelListFetchGateTests.swift @@ -0,0 +1,108 @@ +// +// AIModelListFetchGateTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("AI model list fetch gate") +struct AIModelListFetchGateTests { + @Test("A provider that cannot fetch a model list is blocked whatever the key") + func blocksWhenNotFetchable() { + for style in [AIProviderType.AuthStyle.apiKey, .optionalApiKey, .oauth, .none] { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: false, takesEndpoint: false, endpoint: "https://h/v1", + authStyle: style, apiKey: "sk-live" + ) + == .notFetchable + ) + } + } + + @Test("A required key that is missing blocks the fetch") + func blocksOnMissingRequiredKey() { + for key in ["", " ", "\n\t"] { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: true, endpoint: "https://h/v1", + authStyle: .apiKey, apiKey: key + ) + == .missingAPIKey + ) + } + } + + @Test("A required key that is present lets the fetch run") + func allowsWithRequiredKey() { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: true, endpoint: "https://h/v1", + authStyle: .apiKey, apiKey: "sk-live" + ) == nil + ) + } + + /// Cursor, xAI, OpenCode Zen and now Custom reach a server that may not want a key at all. + @Test("A provider whose key is optional never blocks on an empty key") + func allowsOptionalKeyProviders() { + for style in [AIProviderType.AuthStyle.optionalApiKey, .none, .oauth] { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: true, endpoint: "https://h/v1", + authStyle: style, apiKey: "" + ) == nil + ) + } + } + + /// A Custom provider is created with no Base URL, and its key is optional, so nothing else + /// would stop the sheet asking a transport with no URL for a model list the moment it opens. + @Test("A provider that takes an endpoint blocks until one is typed") + func blocksOnMissingEndpoint() { + for endpoint in ["", " "] { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: true, endpoint: endpoint, + authStyle: .optionalApiKey, apiKey: "" + ) == .missingEndpoint + ) + } + } + + @Test("A provider that reaches a fixed host never blocks on the endpoint") + func ignoresEndpointWhenNotConfigurable() { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: false, endpoint: "", + authStyle: .oauth, apiKey: "" + ) == nil + ) + } + + @Test("A missing endpoint is reported before a missing key") + func endpointOutranksTheKey() { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, takesEndpoint: true, endpoint: "", + authStyle: .apiKey, apiKey: "" + ) == .missingEndpoint + ) + } + + @Test("Custom does not block on an empty key") + func customIsNotBlocked() { + #expect( + AIModelListFetchGate.blocker( + fetchesModelList: true, + takesEndpoint: true, + endpoint: "https://api.z.ai/api/paas/v4", + authStyle: AIProviderType.custom.authStyle, + apiKey: "" + ) == nil + ) + } +} diff --git a/TableProTests/Core/AI/AIProviderModelFetchTests.swift b/TableProTests/Core/AI/AIProviderModelFetchTests.swift index 80e1bb071c..94095e9b1c 100644 --- a/TableProTests/Core/AI/AIProviderModelFetchTests.swift +++ b/TableProTests/Core/AI/AIProviderModelFetchTests.swift @@ -115,6 +115,55 @@ struct AIProviderModelFetchTests { #expect(StubModelListProtocol.lastRequestedURL() == "https://gateway.internal/openai/v2/models") } + private func compatibleProvider(_ type: AIProviderType, endpoint: String) -> OpenAICompatibleProvider { + OpenAICompatibleProvider( + endpoint: endpoint, + apiKey: "key", + providerType: type, + session: stubSession() + ) + } + + @Test("An OpenAI-compatible server that serves no models answers with an empty list") + func openAICompatibleEmptyListIsNotAnError() async throws { + StubModelListProtocol.respond(status: 200, body: #"{"data":[]}"#) + let models = try await compatibleProvider(.custom, endpoint: "https://host/v1").fetchAvailableModels() + #expect(models.isEmpty) + } + + /// An empty picker with no error reads as "this server has no models", which is not what a + /// gateway answering 200 with the wrong shape is saying. + @Test("A 200 whose JSON has no model array is reported, not read as an empty list") + func openAICompatibleWrongShapeThrows() async { + StubModelListProtocol.respond(status: 200, body: #"{"models":[{"name":"a"}]}"#) + await #expect(throws: AIProviderError.self) { + _ = try await compatibleProvider(.custom, endpoint: "https://host/v1").fetchAvailableModels() + } + } + + @Test("A 200 that is not JSON at all is reported") + func openAICompatibleNonJSONThrows() async { + StubModelListProtocol.respond(status: 200, body: "") + await #expect(throws: AIProviderError.self) { + _ = try await compatibleProvider(.custom, endpoint: "https://host/v1").fetchAvailableModels() + } + } + + @Test("An Ollama server with nothing pulled answers with an empty list") + func ollamaEmptyListIsNotAnError() async throws { + StubModelListProtocol.respond(status: 200, body: #"{"models":[]}"#) + let models = try await compatibleProvider(.ollama, endpoint: "http://localhost:11434").fetchAvailableModels() + #expect(models.isEmpty) + } + + @Test("An Ollama route answering the wrong shape is reported") + func ollamaWrongShapeThrows() async { + StubModelListProtocol.respond(status: 200, body: #"{"data":[{"id":"a"}]}"#) + await #expect(throws: AIProviderError.self) { + _ = try await compatibleProvider(.ollama, endpoint: "http://localhost:11434").fetchAvailableModels() + } + } + @Test("Gemini reaches the model list under the base the user configured") func geminiUsesTheResolvedBase() async throws { StubModelListProtocol.respond(status: 200, body: #"{"models":[]}"#) diff --git a/TableProTests/Core/AI/CustomProviderRegistrationTests.swift b/TableProTests/Core/AI/CustomProviderRegistrationTests.swift new file mode 100644 index 0000000000..7f8f167f5b --- /dev/null +++ b/TableProTests/Core/AI/CustomProviderRegistrationTests.swift @@ -0,0 +1,42 @@ +// +// CustomProviderRegistrationTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Custom provider registration") +struct CustomProviderRegistrationTests { + private func descriptor() -> AIProviderDescriptor? { + AIProviderRegistration.registerAll() + return AIProviderRegistry.shared.descriptor(for: AIProviderType.custom.rawValue) + } + + /// An OpenAI-compatible server with authentication turned off, such as a self-hosted vLLM or + /// LM Studio, has no key to paste, and requiring one left Save permanently dimmed. + @Test("A custom provider's API key is optional") + func apiKeyIsOptional() { + #expect(AIProviderType.custom.authStyle == .optionalApiKey) + #expect(AIProviderType.custom.authStyle.usesAPIKey) + } + + @Test("A custom provider still fetches its model list and takes an endpoint and a name") + func keepsItsCapabilities() throws { + let entry = try #require(descriptor()) + #expect(entry.allowsEndpointConfiguration) + #expect(entry.allowsNameConfiguration) + #expect(entry.fetchesModelList) + #expect(entry.allowsMaxOutputTokens) + } + + @Test("A custom provider builds the OpenAI-compatible transport with no key") + func buildsWithoutAKey() throws { + let entry = try #require(descriptor()) + let config = AIProviderConfig(type: .custom, model: "glm-4.6", endpoint: "https://api.z.ai/api/paas/v4") + #expect(entry.makeProvider(config, nil) is OpenAICompatibleProvider) + #expect(entry.makeProvider(config, "") is OpenAICompatibleProvider) + } +} diff --git a/TableProTests/Core/Utilities/LoopbackHostTests.swift b/TableProTests/Core/Utilities/LoopbackHostTests.swift new file mode 100644 index 0000000000..d80d86606d --- /dev/null +++ b/TableProTests/Core/Utilities/LoopbackHostTests.swift @@ -0,0 +1,41 @@ +// +// LoopbackHostTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Loopback host") +struct LoopbackHostTests { + @Test("The named loopback spellings are loopback") + func acceptsNames() { + for host in ["localhost", "LOCALHOST", "localhost.", " localhost ", "::1", "[::1]"] { + #expect(LoopbackHost.isLoopback(host), "\(host) should be loopback") + } + } + + /// The whole of 127.0.0.0/8 is loopback, not only 127.0.0.1. Docker and ddev hand out the rest. + @Test("The whole 127 range is loopback") + func acceptsTheWholeRange() { + for host in ["127.0.0.1", "127.0.0.2", "127.1.2.3", "127.255.255.255"] { + #expect(LoopbackHost.isLoopback(host), "\(host) should be loopback") + } + } + + @Test("A remote host is not loopback") + func rejectsRemoteHosts() { + for host in ["api.openai.com", "192.168.1.10", "10.0.0.1", "0.0.0.0", "128.0.0.1", ""] { + #expect(!LoopbackHost.isLoopback(host), "\(host) should not be loopback") + } + } + + @Test("A malformed address is not loopback") + func rejectsMalformed() { + for host in ["127.0.0", "127.0.0.1.1", "127.0.0.256", "127.a.b.c", "127..0.1", "localhost:1234"] { + #expect(!LoopbackHost.isLoopback(host), "\(host) should not be loopback") + } + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 71b58ade55..803d2caa24 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -22,7 +22,7 @@ Open **Settings > AI** (`Cmd+,`). **Enable AI Features** at the top gates the wh Paste the API base from the server's own documentation, version segment included: `https://api.openai.com/v1`, `https://api.z.ai/api/paas/v4`, `http://localhost:1234/v1`. Under the field is the URL a request resolves to. A full chat completions URL works as well and is used as it stands. - Paste an API key, or sign in for GitHub Copilot, ChatGPT, Cursor, and xAI. + Paste an API key, or sign in for GitHub Copilot, ChatGPT, Cursor, and xAI. Leave the key empty for a Custom provider whose server checks none, such as a self-hosted vLLM or LM Studio with authentication off. Type a model name or pick one from the fetched list, then click **Test Connection**.