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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Core/AI/AIEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
34 changes: 34 additions & 0 deletions TablePro/Core/AI/AIModelListFetchGate.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
30 changes: 20 additions & 10 deletions TablePro/Core/AI/OpenAICompatibleProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable {
internal let username: String
internal let scopeName: String

private static let loopbackHosts: Set<String> = ["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()
Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions TablePro/Core/Utilities/LoopbackHost.swift
Original file line number Diff line number Diff line change
@@ -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<String> = ["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
}
}
1 change: 1 addition & 0 deletions TablePro/Models/AI/AIModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
68 changes: 57 additions & 11 deletions TablePro/Views/Settings/AIProviderDetailSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ struct AIProviderDetailSheet: View {
Section {
SecureField(String(localized: "API Key"), text: $apiKey)
.onChange(of: apiKey) { _ in
scheduleFetchModels()
testResult = nil
}
HStack {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions TableProTests/Core/AI/AIEndpointTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading