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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Endpoint** is now **Base URL** in an AI provider's settings, with the URL requests resolve to shown under it. (#3040)
- 537 driver and import/export strings are now translatable, having only ever shown in English.
- Middle-dot separators dropped from the assistant transcript, slash command list and model picker.
- Every plugin bundle compiled under the same concurrency settings as the app that loads it.
Expand Down Expand Up @@ -68,6 +69,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- 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.
- "unsupported URL" instead of TablePro's own message when the Base URL had no `https://`.
- Claude and Gemini filling the model picker from a built-in list when the server had rejected the request.
- A wrong Base URL reported as **Model not found**.
- Editing a saved provider in Settings replacing the transport of a conversation already streaming through it.
- **Save Changes** and ⌘S dim on a Users & Roles tab with staged changes.
- A deleted connection's inspector and assistant choice left behind, and inherited by a new connection with its id.
- **Show Results** enabled on tabs that have no results pane.
Expand Down
121 changes: 121 additions & 0 deletions TablePro/Core/AI/AIEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// AIEndpoint.swift
// TablePro
//

import Foundation

enum AIEndpointStyle: Sendable, CaseIterable {
case chatCompletions
case responses
case messages
case gemini
case ollama

var apiVersion: String? {
switch self {
case .chatCompletions, .responses, .messages: return "v1"
case .gemini: return "v1beta"
case .ollama: return nil
}
}

var resourceTerminals: [String] {
switch self {
case .chatCompletions: return ["chat/completions", "completions", "models"]
case .responses: return ["responses", "models"]
case .messages: return ["messages", "models"]
case .gemini: return ["models"]
case .ollama: return ["api/chat", "api/tags"]
}
}

var modelsResource: String {
switch self {
case .chatCompletions, .responses, .messages, .gemini: return "models"
case .ollama: return "api/tags"
}
}

func chatResource(model: String) -> String {
switch self {
case .chatCompletions: return "chat/completions"
case .responses: return "responses"
case .messages: return "messages"
case .gemini: return "models/\(model):streamGenerateContent"
case .ollama: return "api/chat"
}
}

var chatQuery: [URLQueryItem] {
switch self {
case .gemini: return [URLQueryItem(name: "alt", value: "sse")]
case .chatCompletions, .responses, .messages, .ollama: return []
}
}
}

struct AIEndpoint: Equatable, Sendable {
let apiBase: URL

init?(_ configured: String, style: AIEndpointStyle) {
let trimmed = configured.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
var components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
let host = components.host,
!host.isEmpty,
components.user == nil,
components.password == nil
else { return nil }

components.percentEncodedPath = Self.apiBasePath(for: components.percentEncodedPath, style: style)
guard let url = components.url else { return nil }
apiBase = url
}

func url(appending resource: String, query: [URLQueryItem] = []) -> URL? {
let target = apiBase.appending(path: resource)
guard !query.isEmpty else { return target }
guard var components = URLComponents(url: target, resolvingAgainstBaseURL: false) else { return nil }
components.queryItems = (components.queryItems ?? []) + query
return components.url
}

func chatURL(model: String, style: AIEndpointStyle) -> URL? {
url(appending: style.chatResource(model: model), query: style.chatQuery)
}

/// 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.
private static func apiBasePath(for percentEncodedPath: String, style: AIEndpointStyle) -> String {
let segments = percentEncodedPath.split(separator: "/").map(String.init)

for terminal in style.resourceTerminals {
let terminalSegments = terminal.split(separator: "/").map(String.init)
guard segments.count >= terminalSegments.count,
Array(segments.suffix(terminalSegments.count)) == terminalSegments
else { continue }
return joined(segments.dropLast(terminalSegments.count))
}

guard let apiVersion = style.apiVersion else { return joined(segments) }
if let last = segments.last, isAPIVersion(last) { return joined(segments) }
return joined(segments + [apiVersion])
}

private static func joined(_ segments: some Collection<String>) -> String {
segments.isEmpty ? "" : "/" + segments.joined(separator: "/")
}

/// `v1`, `v4`, `v1beta`, `v2alpha1`. A leading digit is required, so `vendor` and `v` are not
/// versions.
private static func isAPIVersion(_ segment: String) -> Bool {
guard segment.first == "v" || segment.first == "V" else { return false }
let rest = segment.dropFirst()
guard let first = rest.first, first.isNumber else { return false }
return rest.allSatisfy { $0.isNumber || $0.isLetter || $0 == "." || $0 == "-" || $0 == "_" }
}
}
22 changes: 16 additions & 6 deletions TablePro/Core/AI/AIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ enum AIProviderError: Error, LocalizedError {
case invalidEndpoint(String)
case authenticationFailed(String)
case rateLimited
case modelNotFound(String)
case notFound(url: String?, detail: String)
case serverError(Int, String)
case networkError(String)
case streamingFailed(String)
Expand All @@ -31,8 +31,17 @@ enum AIProviderError: Error, LocalizedError {
return String(format: String(localized: "Authentication failed: %@"), detail)
case .rateLimited:
return String(localized: "Rate limited. Please try again later.")
case .modelNotFound(let model):
return String(format: String(localized: "Model not found: %@"), model)
case .notFound(let url, let detail):
let message: String
if let url, !url.isEmpty {
message = String(
format: String(localized: "Not found (404) at %@. Check the Base URL and the model."),
url
)
} else {
message = String(localized: "Not found (404). Check the Base URL and the model.")
}
return detail.isEmpty ? message : "\(message) \(detail)"
case .serverError(let code, let message):
return String(format: String(localized: "Server error (%d): %@"), code, message)
case .networkError(let message):
Expand All @@ -45,7 +54,8 @@ enum AIProviderError: Error, LocalizedError {
static func mapHTTPError(
statusCode: Int,
body: String,
treatForbiddenAsAuthFailure: Bool = false
treatForbiddenAsAuthFailure: Bool = false,
requestURL: URL? = nil
) -> AIProviderError {
let message = parseErrorMessage(from: body) ?? body
switch statusCode {
Expand All @@ -56,7 +66,7 @@ enum AIProviderError: Error, LocalizedError {
case 429:
return .rateLimited
case 404:
return .modelNotFound(message)
return .notFound(url: requestURL?.absoluteString, detail: message)
default:
return .serverError(statusCode, message)
}
Expand All @@ -75,7 +85,7 @@ enum AIProviderError: Error, LocalizedError {

var isRetryable: Bool {
switch self {
case .invalidEndpoint, .authenticationFailed, .modelNotFound:
case .invalidEndpoint, .authenticationFailed, .notFound:
return false
case .rateLimited, .serverError, .networkError, .streamingFailed:
return true
Expand Down
29 changes: 17 additions & 12 deletions TablePro/Core/AI/AIProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,28 @@ enum AIProviderFactory {
if let cached = cache[config.id], cached.apiKey == apiKey, cached.config == config {
return cached.provider
}
let provider: ChatTransport
if let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) {
provider = descriptor.makeProvider(config, apiKey)
} else {
provider = OpenAICompatibleProvider(
endpoint: config.endpoint,
apiKey: apiKey,
providerType: config.type,
model: config.model,
maxOutputTokens: config.maxOutputTokens
)
}
let provider = makeUncachedProvider(for: config, apiKey: apiKey)
cache[config.id] = (config, apiKey, provider)
return provider
}
}

/// A transport built outside the per-id cache, for testing a configuration that is still being
/// edited. The Settings sheet works on a copy of a saved provider under the same id, so caching
/// a half-typed endpoint would hand it to the session already streaming through that provider.
static func makeUncachedProvider(for config: AIProviderConfig, apiKey: String?) -> ChatTransport {
guard let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) else {
return OpenAICompatibleProvider(
endpoint: config.endpoint,
apiKey: apiKey,
providerType: config.type,
model: config.model,
maxOutputTokens: config.maxOutputTokens
)
}
return descriptor.makeProvider(config, apiKey)
}

static func invalidateCache() {
cacheLock.withLock { $0.removeAll() }
}
Expand Down
44 changes: 31 additions & 13 deletions TablePro/Core/AI/AnthropicProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ final class AnthropicProvider: ChatTransport {
private static let logger = Logger(subsystem: "com.TablePro", category: "AnthropicProvider")

private let endpoint: String
private let resolvedEndpoint: AIEndpoint?
private let apiKey: String
private let model: String
private let maxOutputTokens: Int
Expand All @@ -21,14 +22,23 @@ final class AnthropicProvider: ChatTransport {
apiKey: String,
model: String = "",
maxOutputTokens: Int = 4_096,
reasoningEffort: ReasoningEffort? = nil
reasoningEffort: ReasoningEffort? = nil,
session: URLSession = URLSession(configuration: .ephemeral)
) {
self.endpoint = endpoint.normalizedEndpoint()
self.endpoint = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
self.resolvedEndpoint = AIEndpoint(endpoint, style: .messages)
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
self.model = model.trimmingCharacters(in: .whitespacesAndNewlines)
self.maxOutputTokens = maxOutputTokens
self.configuredEffort = reasoningEffort
self.session = URLSession(configuration: .ephemeral)
self.session = session
}

private func requestURL(_ resource: String) throws -> URL {
guard let url = resolvedEndpoint?.url(appending: resource) else {
throw AIProviderError.invalidEndpoint(endpoint)
}
return url
}

func streamChat(
Expand All @@ -52,9 +62,7 @@ final class AnthropicProvider: ChatTransport {
}

func fetchAvailableModels() async throws -> [AIModelInfo] {
guard let url = URL(string: "\(endpoint)/v1/models") else {
throw AIProviderError.invalidEndpoint(endpoint)
}
let url = try requestURL(AIEndpointStyle.messages.modelsResource)

var request = URLRequest(url: url)
request.httpMethod = "GET"
Expand All @@ -71,9 +79,21 @@ final class AnthropicProvider: ChatTransport {
return Self.offlineModels
}

guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
guard let httpResponse = response as? HTTPURLResponse else {
Self.logger.warning("Anthropic model fetch returned no HTTP response; using known models")
return Self.offlineModels
}

guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw AIProviderError.mapHTTPError(
statusCode: httpResponse.statusCode,
body: body,
requestURL: url
)
}

guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let models = json["data"] as? [[String: Any]]
else {
Self.logger.warning("Anthropic model fetch returned unexpected response; using known models")
Expand Down Expand Up @@ -178,7 +198,7 @@ final class AnthropicProvider: ChatTransport {
}

let body = String(data: data, encoding: .utf8) ?? ""
throw AIProviderError.mapHTTPError(statusCode: statusCode, body: body)
throw AIProviderError.mapHTTPError(statusCode: statusCode, body: body, requestURL: request.url)
}

private func buildMessagesRequest(
Expand All @@ -187,9 +207,7 @@ final class AnthropicProvider: ChatTransport {
stream: Bool = true,
effort: ReasoningEffort?
) throws -> URLRequest {
guard let url = URL(string: "\(endpoint)/v1/messages") else {
throw AIProviderError.invalidEndpoint(endpoint)
}
let url = try requestURL(AIEndpointStyle.messages.chatResource(model: options.model))

var request = URLRequest(url: url)
request.httpMethod = "POST"
Expand Down
Loading
Loading