diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fcd72db9..a2c417c534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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. diff --git a/TablePro/Core/AI/AIEndpoint.swift b/TablePro/Core/AI/AIEndpoint.swift new file mode 100644 index 0000000000..3d875eede8 --- /dev/null +++ b/TablePro/Core/AI/AIEndpoint.swift @@ -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 { + 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 == "_" } + } +} diff --git a/TablePro/Core/AI/AIProvider.swift b/TablePro/Core/AI/AIProvider.swift index 79f8999543..c8142f0ed8 100644 --- a/TablePro/Core/AI/AIProvider.swift +++ b/TablePro/Core/AI/AIProvider.swift @@ -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) @@ -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): @@ -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 { @@ -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) } @@ -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 diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index 0efa81c3b8..e9b188d5cb 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -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() } } diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index 17dd79eb33..2e699a3200 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -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 @@ -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( @@ -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" @@ -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") @@ -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( @@ -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" diff --git a/TablePro/Core/AI/GeminiProvider.swift b/TablePro/Core/AI/GeminiProvider.swift index 58b1d907ae..3eb5fc79f1 100644 --- a/TablePro/Core/AI/GeminiProvider.swift +++ b/TablePro/Core/AI/GeminiProvider.swift @@ -10,15 +10,29 @@ final class GeminiProvider: ChatTransport { private static let logger = Logger(subsystem: "com.TablePro", category: "GeminiProvider") private let endpoint: String + private let resolvedEndpoint: AIEndpoint? private let apiKey: String private let maxOutputTokens: Int private let session: URLSession - init(endpoint: String, apiKey: String, maxOutputTokens: Int = 8_192) { - self.endpoint = endpoint.normalizedEndpoint() + init( + endpoint: String, + apiKey: String, + maxOutputTokens: Int = 8_192, + session: URLSession = URLSession(configuration: .ephemeral) + ) { + self.endpoint = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + self.resolvedEndpoint = AIEndpoint(endpoint, style: .gemini) self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) self.maxOutputTokens = maxOutputTokens - self.session = URLSession(configuration: .ephemeral) + self.session = session + } + + private func requestURL(_ resource: String, query: [URLQueryItem] = []) throws -> URL { + guard let url = resolvedEndpoint?.url(appending: resource, query: query) else { + throw AIProviderError.invalidEndpoint(endpoint) + } + return url } func streamChat( @@ -52,9 +66,7 @@ final class GeminiProvider: ChatTransport { } func fetchAvailableModels() async throws -> [AIModelInfo] { - guard let url = URL(string: "\(endpoint)/v1beta/models") else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(AIEndpointStyle.gemini.modelsResource) var request = URLRequest(url: url) request.httpMethod = "GET" @@ -70,9 +82,22 @@ final class GeminiProvider: 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("Gemini 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, + treatForbiddenAsAuthFailure: true, + requestURL: url + ) + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let models = json["models"] as? [[String: Any]] else { Self.logger.warning("Gemini model fetch returned unexpected response; using known models") @@ -105,9 +130,7 @@ final class GeminiProvider: ChatTransport { } func testConnection() async throws -> Bool { - guard let url = URL(string: "\(endpoint)/v1beta/models") else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(AIEndpointStyle.gemini.modelsResource) var request = URLRequest(url: url) request.httpMethod = "GET" @@ -130,7 +153,8 @@ final class GeminiProvider: ChatTransport { throw AIProviderError.mapHTTPError( statusCode: statusCode, body: body, - treatForbiddenAsAuthFailure: true + treatForbiddenAsAuthFailure: true, + requestURL: url ) } @@ -141,12 +165,8 @@ final class GeminiProvider: ChatTransport { turns: [ChatTurnWire], options: ChatTransportOptions ) throws -> URLRequest { - guard let encodedModel = options.model.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), - let url = URL( - string: "\(endpoint)/v1beta/models/\(encodedModel):streamGenerateContent?alt=sse" - ) else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let style = AIEndpointStyle.gemini + let url = try requestURL(style.chatResource(model: options.model), query: style.chatQuery) var request = URLRequest(url: url) request.httpMethod = "POST" diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 8be42e3c97..0760dd5fe8 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -13,6 +13,8 @@ final class OpenAICompatibleProvider: ChatTransport { ) private let endpoint: String + private let style: AIEndpointStyle + private let resolvedEndpoint: AIEndpoint? private let apiKey: String? private let providerType: AIProviderType private let model: String @@ -30,7 +32,10 @@ final class OpenAICompatibleProvider: ChatTransport { maxOutputTokens: Int? = nil, session: URLSession = URLSession(configuration: .ephemeral) ) { - self.endpoint = endpoint.normalizedEndpoint() + let style = providerType.endpointStyle + self.endpoint = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + self.style = style + self.resolvedEndpoint = AIEndpoint(endpoint, style: style) self.apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) self.providerType = providerType self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) @@ -38,6 +43,13 @@ final class OpenAICompatibleProvider: ChatTransport { 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( turns: [ChatTurnWire], options: ChatTransportOptions @@ -232,9 +244,7 @@ final class OpenAICompatibleProvider: ChatTransport { ) } default: - guard let url = URL(string: endpoint.openAIPath("chat/completions")) else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(style.chatResource(model: testConnectionModel)) var request = URLRequest(url: url) request.httpMethod = "POST" @@ -261,32 +271,36 @@ final class OpenAICompatibleProvider: ChatTransport { return false } - let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "" - let isJSON = contentType.contains("application/json") - || (try? JSONSerialization.jsonObject(with: data)) != nil + let statusCode = httpResponse.statusCode - if httpResponse.statusCode == 401 { + if statusCode == 401 { throw AIProviderError.authenticationFailed("") } - if !isJSON { - return false + if statusCode == 200 || statusCode == 400 { + return Self.looksLikeAnAPIResponse(data: data, response: httpResponse) } + let errorBody = String(data: data, encoding: .utf8) ?? "" + throw AIProviderError.mapHTTPError(statusCode: statusCode, body: errorBody, requestURL: url) + } + } + + /// A wrong Base URL often lands on a reverse proxy's login page or a single-page app's + /// fallback route, both of which answer 200 with HTML that the chat stream cannot read. + private static func looksLikeAnAPIResponse(data: Data, response: HTTPURLResponse) -> Bool { + let contentType = response.value(forHTTPHeaderField: "Content-Type")?.lowercased() ?? "" + if contentType.contains("application/json") || contentType.contains("text/event-stream") { return true } + return (try? JSONSerialization.jsonObject(with: data)) != nil } private func buildChatCompletionRequest( turns: [ChatTurnWire], options: ChatTransportOptions ) throws -> URLRequest { - let urlString = providerType == .ollama - ? "\(endpoint)/api/chat" - : endpoint.openAIPath("chat/completions") - guard let url = URL(string: urlString) else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(style.chatResource(model: options.model)) var request = URLRequest(url: url) request.httpMethod = "POST" @@ -457,9 +471,7 @@ final class OpenAICompatibleProvider: ChatTransport { } private func fetchOpenAIModels() async throws -> [String] { - guard let url = URL(string: endpoint.openAIPath("models")) else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(style.modelsResource) var request = URLRequest(url: url) request.timeoutInterval = AIProvider.modelListTimeout @@ -476,13 +488,24 @@ final class OpenAICompatibleProvider: ChatTransport { (data, response) = try await session.data(for: request) } catch { Self.logger.warning("OpenAI-compatible model fetch failed: \(error.publicLogShape, privacy: .public)") - throw AIProviderError.networkError("Failed to fetch models") + throw AIProviderError.networkError( + String(format: String(localized: "Failed to fetch models from %@"), url.absoluteString) + ) } - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - throw AIProviderError.networkError("Failed to fetch models") + guard let httpResponse = response as? HTTPURLResponse else { + throw AIProviderError.networkError( + String(format: String(localized: "Failed to fetch models from %@"), url.absoluteString) + ) + } + + 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) @@ -496,9 +519,7 @@ final class OpenAICompatibleProvider: ChatTransport { } private func fetchOllamaModels() async throws -> [String] { - guard let url = URL(string: "\(endpoint)/api/tags") else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(style.modelsResource) var request = URLRequest(url: url) request.timeoutInterval = AIProvider.modelListTimeout diff --git a/TablePro/Core/AI/OpenAIResponsesProvider.swift b/TablePro/Core/AI/OpenAIResponsesProvider.swift index 8a0b4f0271..6ba546cd3e 100644 --- a/TablePro/Core/AI/OpenAIResponsesProvider.swift +++ b/TablePro/Core/AI/OpenAIResponsesProvider.swift @@ -22,6 +22,7 @@ final class OpenAIResponsesProvider: ChatTransport { private static let logger = Logger(subsystem: "com.TablePro", category: "OpenAIResponsesProvider") private let endpoint: String + private let resolvedEndpoint: AIEndpoint? private let apiKey: String? private let model: String private let maxOutputTokens: Int? @@ -36,7 +37,8 @@ final class OpenAIResponsesProvider: ChatTransport { dialect: ResponsesDialect = .openAI, session: URLSession = URLSession(configuration: .ephemeral) ) { - self.endpoint = endpoint.normalizedEndpoint() + self.endpoint = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + self.resolvedEndpoint = AIEndpoint(endpoint, style: .responses) self.apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) self.model = model.trimmingCharacters(in: .whitespacesAndNewlines) self.maxOutputTokens = maxOutputTokens @@ -44,6 +46,13 @@ final class OpenAIResponsesProvider: ChatTransport { 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( turns: [ChatTurnWire], options: ChatTransportOptions @@ -59,9 +68,7 @@ final class OpenAIResponsesProvider: ChatTransport { } private func fetchModelIDs() async throws -> [String] { - guard let url = URL(string: "\(endpoint)/v1/models") else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(AIEndpointStyle.responses.modelsResource) var request = URLRequest(url: url) request.timeoutInterval = AIProvider.modelListTimeout if let apiKey, !apiKey.isEmpty { @@ -99,7 +106,11 @@ final class OpenAIResponsesProvider: ChatTransport { throw AIProviderError.authenticationFailed("") } let body = String(data: data, encoding: .utf8) ?? "" - throw AIProviderError.mapHTTPError(statusCode: httpResponse.statusCode, body: body) + throw AIProviderError.mapHTTPError( + statusCode: httpResponse.statusCode, + body: body, + requestURL: request.url + ) } private func buildRequest( @@ -107,9 +118,7 @@ final class OpenAIResponsesProvider: ChatTransport { options: ChatTransportOptions, stream: Bool ) throws -> URLRequest { - guard let url = URL(string: "\(endpoint)/v1/responses") else { - throw AIProviderError.invalidEndpoint(endpoint) - } + let url = try requestURL(AIEndpointStyle.responses.chatResource(model: options.model)) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") diff --git a/TablePro/Core/AI/SSEEventStream.swift b/TablePro/Core/AI/SSEEventStream.swift index 113e8ec7fe..bc063daa70 100644 --- a/TablePro/Core/AI/SSEEventStream.swift +++ b/TablePro/Core/AI/SSEEventStream.swift @@ -19,7 +19,8 @@ enum SSEEventStream { AsyncThrowingStream { continuation in let task = Task { do { - var (bytes, response) = try await session.bytes(for: buildRequest()) + let request = try await buildRequest() + var (bytes, response) = try await session.bytes(for: request) if (response as? HTTPURLResponse)?.statusCode == 401, let refreshOnUnauthorized { try await refreshOnUnauthorized() @@ -34,7 +35,8 @@ enum SSEEventStream { throw AIProviderError.mapHTTPError( statusCode: httpResponse.statusCode, body: body, - treatForbiddenAsAuthFailure: treatForbiddenAsAuthFailure + treatForbiddenAsAuthFailure: treatForbiddenAsAuthFailure, + requestURL: request.url ) } diff --git a/TablePro/Core/AI/String+AIEndpoint.swift b/TablePro/Core/AI/String+AIEndpoint.swift deleted file mode 100644 index 973dca9db9..0000000000 --- a/TablePro/Core/AI/String+AIEndpoint.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// String+AIEndpoint.swift -// TablePro -// - -import Foundation - -extension String { - func normalizedEndpoint() -> String { - hasSuffix("/") ? String(dropLast()) : self - } - - func openAIPath(_ resource: String) -> String { - let base = normalizedEndpoint() - return base.hasSuffix("/v1") ? "\(base)/\(resource)" : "\(base)/v1/\(resource)" - } -} diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 75f24358b1..a283424eb9 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -84,6 +84,18 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { } } + /// How the configured endpoint is turned into a request URL. Providers that reach a fixed + /// host ignore it, so the fall-through matches `AIProviderFactory`'s own fallback transport. + var endpointStyle: AIEndpointStyle { + switch self { + case .claude: return .messages + case .openAI, .xai: return .responses + case .gemini: return .gemini + case .ollama: return .ollama + default: return .chatCompletions + } + } + var symbolName: String { switch self { case .copilot: return "chevron.left.forwardslash.chevron.right" diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 359949fc8c..e032bf9183 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -665,11 +665,16 @@ struct AIProviderDetailSheet: View { TextField(String(localized: "Name"), text: $draft.name) } if allowsEndpointField { - TextField(String(localized: "Endpoint"), text: $draft.endpoint) - .onChange(of: draft.endpoint) { _ in - scheduleFetchModels() - testResult = nil - } + TextField( + String(localized: "Base URL"), + text: $draft.endpoint, + prompt: Text(draft.type.defaultEndpoint) + ) + .onChange(of: draft.endpoint) { _ in + scheduleFetchModels() + testResult = nil + } + endpointFootnote } } header: { Text("Connection") @@ -677,12 +682,44 @@ struct AIProviderDetailSheet: View { } } + /// Placeholder text disappears as soon as the field is typed in, so the rule the server's own + /// documentation follows is spelled out beside the field, and the URL it resolves to is shown + /// back rather than left to be inferred. + @ViewBuilder + private var endpointFootnote: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Include the version segment your server uses, such as /v1 or /v4.") + if let resolvedChatURL { + Text(resolvedChatURL) + .textSelection(.enabled) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + + 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 } + return url.absoluteString + } + private var allowsNameField: Bool { descriptor?.allowsNameConfiguration == true } private var allowsEndpointField: Bool { - descriptor?.allowsEndpointConfiguration == true + descriptor?.allowsEndpointConfiguration == true && endpointDrivesRequests + } + + /// xAI reaches its own proxy rather than the configured base while it is running on a + /// subscription sign-in, so the field and the URL under it would both describe a request the + /// app is not going to make. + private var endpointDrivesRequests: Bool { + guard draft.type == .xai else { return true } + return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var shouldShowConnectionSection: Bool { @@ -950,7 +987,7 @@ struct AIProviderDetailSheet: View { return } - let provider = AIProviderFactory.createProvider(for: normalizedDraft, apiKey: apiKey) + let provider = AIProviderFactory.makeUncachedProvider(for: normalizedDraft, apiKey: apiKey) isFetchingModels = true modelFetchError = nil @@ -980,7 +1017,7 @@ struct AIProviderDetailSheet: View { return } - let provider = AIProviderFactory.createProvider(for: normalizedDraft, apiKey: apiKey) + let provider = AIProviderFactory.makeUncachedProvider(for: normalizedDraft, apiKey: apiKey) isTesting = true testResult = nil diff --git a/TableProTests/Core/AI/AIEndpointTests.swift b/TableProTests/Core/AI/AIEndpointTests.swift new file mode 100644 index 0000000000..1584d57a19 --- /dev/null +++ b/TableProTests/Core/AI/AIEndpointTests.swift @@ -0,0 +1,152 @@ +// +// AIEndpointTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("AI Endpoint Resolution") +struct AIEndpointTests { + private func chatURL(_ configured: String, _ style: AIEndpointStyle, model: String = "m") -> String? { + AIEndpoint(configured, style: style)?.chatURL(model: model, style: style)?.absoluteString + } + + private func modelsURL(_ configured: String, _ style: AIEndpointStyle) -> String? { + AIEndpoint(configured, style: style)?.url(appending: style.modelsResource)?.absoluteString + } + + @Test("A base with no version segment gains the style's version") + func insertsVersionWhenAbsent() { + #expect(chatURL("https://api.openai.com", .chatCompletions) == "https://api.openai.com/v1/chat/completions") + #expect(modelsURL("https://api.openai.com", .chatCompletions) == "https://api.openai.com/v1/models") + #expect(chatURL("https://openrouter.ai/api", .chatCompletions) == "https://openrouter.ai/api/v1/chat/completions") + #expect(chatURL("https://opencode.ai/zen", .chatCompletions) == "https://opencode.ai/zen/v1/chat/completions") + #expect(chatURL("http://localhost:8080", .chatCompletions) == "http://localhost:8080/v1/chat/completions") + } + + @Test("A base already ending in /v1 is not doubled") + func doesNotDoubleTheVersion() { + #expect(chatURL("https://opencode.ai/zen/v1", .chatCompletions) == "https://opencode.ai/zen/v1/chat/completions") + #expect(modelsURL("https://opencode.ai/zen/v1", .chatCompletions) == "https://opencode.ai/zen/v1/models") + #expect(chatURL("https://api.anthropic.com/v1", .messages) == "https://api.anthropic.com/v1/messages") + #expect(chatURL("https://api.openai.com/v1", .responses) == "https://api.openai.com/v1/responses") + } + + /// The reported defect: Z.ai serves its OpenAI-compatible API under /v4. + @Test("A version segment other than v1 is left alone") + func honoursANonV1Version() { + #expect(chatURL("https://api.z.ai/api/paas/v4", .chatCompletions) + == "https://api.z.ai/api/paas/v4/chat/completions") + #expect(modelsURL("https://api.z.ai/api/paas/v4", .chatCompletions) + == "https://api.z.ai/api/paas/v4/models") + #expect(chatURL("https://host/v2alpha1", .chatCompletions) == "https://host/v2alpha1/chat/completions") + #expect(chatURL("https://host/v1.5", .chatCompletions) == "https://host/v1.5/chat/completions") + } + + @Test("A path segment that only starts with v is not a version") + func doesNotTreatWordsAsVersions() { + #expect(chatURL("https://host/vertex", .chatCompletions) == "https://host/vertex/v1/chat/completions") + #expect(chatURL("https://host/v", .chatCompletions) == "https://host/v/v1/chat/completions") + } + + @Test("A full resource URL is used as it stands, and its sibling resolves beside it") + func acceptsAFullResourceURL() { + #expect(chatURL("https://api.z.ai/api/paas/v4/chat/completions", .chatCompletions) + == "https://api.z.ai/api/paas/v4/chat/completions") + #expect(modelsURL("https://api.z.ai/api/paas/v4/chat/completions", .chatCompletions) + == "https://api.z.ai/api/paas/v4/models") + #expect(chatURL("https://proxy.internal/openai/chat/completions", .chatCompletions) + == "https://proxy.internal/openai/chat/completions") + #expect(chatURL("https://api.anthropic.com/v1/messages", .messages) + == "https://api.anthropic.com/v1/messages") + #expect(chatURL("https://api.openai.com/v1/responses", .responses) + == "https://api.openai.com/v1/responses") + #expect(modelsURL("https://api.openai.com/v1/models", .responses) == "https://api.openai.com/v1/models") + } + + @Test("Trailing slashes are stripped however many there are") + func stripsTrailingSlashes() { + #expect(modelsURL("https://api.openai.com/", .chatCompletions) == "https://api.openai.com/v1/models") + #expect(modelsURL("https://opencode.ai/zen/v1/", .chatCompletions) == "https://opencode.ai/zen/v1/models") + #expect(modelsURL("https://opencode.ai/zen/v1///", .chatCompletions) == "https://opencode.ai/zen/v1/models") + } + + @Test("A query string on the base survives, after the appended path") + func preservesTheBaseQuery() { + #expect(chatURL("https://x.openai.azure.com/openai/deployments/dep?api-version=2026-02-01", .chatCompletions) + == "https://x.openai.azure.com/openai/deployments/dep/v1/chat/completions?api-version=2026-02-01") + } + + /// `URLComponents.path` decodes `%2F`, which would turn one path segment into two and address + /// a different route on the gateway. + @Test("An escaped path separator survives resolution") + func preservesAnEscapedSeparator() { + #expect(chatURL("https://gateway.example/tenant%2Fapi/v4", .chatCompletions) + == "https://gateway.example/tenant%2Fapi/v4/chat/completions") + #expect(modelsURL("https://gateway.example/tenant%2Fapi", .chatCompletions) + == "https://gateway.example/tenant%2Fapi/v1/models") + } + + @Test("Surrounding whitespace is ignored") + func trimsWhitespace() { + #expect(chatURL(" https://api.openai.com/v1 ", .chatCompletions) + == "https://api.openai.com/v1/chat/completions") + } + + @Test("Gemini keeps its own version segment and query") + func resolvesGemini() { + #expect(chatURL("https://generativelanguage.googleapis.com", .gemini, model: "gemini-3-pro") + == "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro:streamGenerateContent?alt=sse") + #expect(modelsURL("https://generativelanguage.googleapis.com", .gemini) + == "https://generativelanguage.googleapis.com/v1beta/models") + #expect(modelsURL("https://generativelanguage.googleapis.com/v1beta", .gemini) + == "https://generativelanguage.googleapis.com/v1beta/models") + #expect(modelsURL("https://gateway.internal/gemini/v1beta/models", .gemini) + == "https://gateway.internal/gemini/v1beta/models") + } + + @Test("A Gemini model name is encoded once, not twice") + func encodesTheGeminiModelOnce() { + #expect(chatURL("https://generativelanguage.googleapis.com/v1beta", .gemini, model: "a b") + == "https://generativelanguage.googleapis.com/v1beta/models/a%20b:streamGenerateContent?alt=sse") + } + + @Test("Ollama keeps its native paths and gains no version") + func resolvesOllama() { + #expect(chatURL("http://localhost:11434", .ollama) == "http://localhost:11434/api/chat") + #expect(modelsURL("http://localhost:11434", .ollama) == "http://localhost:11434/api/tags") + #expect(chatURL("http://localhost:11434/", .ollama) == "http://localhost:11434/api/chat") + #expect(chatURL("http://localhost:11434/api/chat", .ollama) == "http://localhost:11434/api/chat") + } + + @Test("An endpoint that is not an http URL is refused") + func refusesUnusableEndpoints() { + #expect(AIEndpoint("api.z.ai/api/paas/v4", style: .chatCompletions) == nil) + #expect(AIEndpoint("", style: .chatCompletions) == nil) + #expect(AIEndpoint(" ", style: .chatCompletions) == nil) + #expect(AIEndpoint("ftp://api.openai.com/v1", style: .chatCompletions) == nil) + #expect(AIEndpoint("file:///tmp/models", style: .chatCompletions) == nil) + #expect(AIEndpoint("https://", style: .chatCompletions) == nil) + } + + /// A key belongs in the Keychain, not in a URL the settings list draws back to the user. + @Test("An endpoint carrying credentials is refused") + func refusesCredentialsInTheEndpoint() { + #expect(AIEndpoint("https://user:secret@host/v1", style: .chatCompletions) == nil) + #expect(AIEndpoint("https://user@host/v1", style: .chatCompletions) == nil) + } + + @Test("Every style resolves the provider's own default endpoint") + func resolvesEveryDefaultEndpoint() { + for type in AIProviderType.allCases where !type.defaultEndpoint.isEmpty { + let style = type.endpointStyle + #expect( + AIEndpoint(type.defaultEndpoint, style: style)?.chatURL(model: "m", style: style) != nil, + "\(type.rawValue) default endpoint does not resolve" + ) + } + } +} diff --git a/TableProTests/Core/AI/AIProviderErrorTests.swift b/TableProTests/Core/AI/AIProviderErrorTests.swift index 731dca9446..f0590c1925 100644 --- a/TableProTests/Core/AI/AIProviderErrorTests.swift +++ b/TableProTests/Core/AI/AIProviderErrorTests.swift @@ -5,9 +5,10 @@ import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @Suite("AIProviderError.isRetryable") struct AIProviderErrorTests { @Test("Transient transport failures are retryable") @@ -20,8 +21,25 @@ struct AIProviderErrorTests { @Test("Configuration errors are not retryable") func configurationErrorsAreNotRetryable() { - #expect(!AIProviderError.modelNotFound("gemini-2.0-flash-lite").isRetryable) + #expect(!AIProviderError.notFound(url: nil, detail: "").isRetryable) #expect(!AIProviderError.authenticationFailed("invalid key").isRetryable) #expect(!AIProviderError.invalidEndpoint("https://broken").isRetryable) } + + @Test("A 404 names the URL it called rather than blaming the model") + func notFoundNamesTheRequestURL() throws { + let url = try #require(URL(string: "https://api.z.ai/api/paas/v4/v1/chat/completions")) + let error = AIProviderError.mapHTTPError(statusCode: 404, body: "", requestURL: url) + let description = try #require(error.errorDescription) + #expect(description.contains("https://api.z.ai/api/paas/v4/v1/chat/completions")) + #expect(!description.contains("Model not found")) + } + + @Test("A 404 keeps the server's own message") + func notFoundKeepsServerDetail() throws { + let body = #"{"error":{"message":"no such model"}}"# + let error = AIProviderError.mapHTTPError(statusCode: 404, body: body) + let description = try #require(error.errorDescription) + #expect(description.contains("no such model")) + } } diff --git a/TableProTests/Core/AI/AIProviderModelFetchTests.swift b/TableProTests/Core/AI/AIProviderModelFetchTests.swift new file mode 100644 index 0000000000..80e1bb071c --- /dev/null +++ b/TableProTests/Core/AI/AIProviderModelFetchTests.swift @@ -0,0 +1,132 @@ +// +// AIProviderModelFetchTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +private final class StubModelListProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + nonisolated(unsafe) private static var status = 200 + nonisolated(unsafe) private static var body = Data() + nonisolated(unsafe) private static var requestedURLs: [String] = [] + + static func respond(status: Int, body: String) { + lock.lock(); defer { lock.unlock() } + Self.status = status + Self.body = Data(body.utf8) + requestedURLs = [] + } + + static func lastRequestedURL() -> String? { + lock.lock(); defer { lock.unlock() } + return requestedURLs.last + } + + private static func record(_ url: String) { + lock.lock(); defer { lock.unlock() } + requestedURLs.append(url) + } + + private static func current() -> (status: Int, body: Data) { + lock.lock(); defer { lock.unlock() } + return (status, body) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + let response = Self.current() + guard let url = request.url, + let httpResponse = HTTPURLResponse( + url: url, statusCode: response.status, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) + else { return } + Self.record(url.absoluteString) + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } +} + +@Suite("AI provider model list", .serialized) +struct AIProviderModelFetchTests { + private func stubSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubModelListProtocol.self] + return URLSession(configuration: config) + } + + /// The model list is the only live probe the settings sheet runs by itself, so answering a + /// rejected request with a hardcoded list made a wrong Base URL look like a healthy provider. + @Test("Claude reports a rejected model list instead of the offline one") + func anthropicSurfacesHTTPFailure() async { + StubModelListProtocol.respond(status: 404, body: #"{"error":{"message":"not found"}}"#) + let provider = AnthropicProvider( + endpoint: "https://api.anthropic.com", + apiKey: "key", + session: stubSession() + ) + await #expect(throws: AIProviderError.self) { + _ = try await provider.fetchAvailableModels() + } + } + + @Test("Gemini reports a rejected model list instead of the offline one") + func geminiSurfacesHTTPFailure() async { + StubModelListProtocol.respond(status: 401, body: #"{"error":{"message":"bad key"}}"#) + let provider = GeminiProvider( + endpoint: "https://generativelanguage.googleapis.com", + apiKey: "key", + session: stubSession() + ) + await #expect(throws: AIProviderError.self) { + _ = try await provider.fetchAvailableModels() + } + } + + @Test("Claude reaches the messages API under the base the user configured") + func anthropicUsesTheResolvedBase() async throws { + StubModelListProtocol.respond(status: 200, body: #"{"data":[{"id":"claude-opus-5"}]}"#) + let provider = AnthropicProvider( + endpoint: "https://api.anthropic.com/v1", + apiKey: "key", + session: stubSession() + ) + _ = try await provider.fetchAvailableModels() + #expect(StubModelListProtocol.lastRequestedURL() == "https://api.anthropic.com/v1/models") + } + + @Test("OpenAI Responses reaches the model list under the base the user configured") + func responsesUsesTheResolvedBase() async throws { + StubModelListProtocol.respond(status: 200, body: #"{"data":[{"id":"gpt-5.5"}]}"#) + let provider = OpenAIResponsesProvider( + endpoint: "https://gateway.internal/openai/v2", + apiKey: "key", + session: stubSession() + ) + _ = try await provider.fetchAvailableModels() + #expect(StubModelListProtocol.lastRequestedURL() == "https://gateway.internal/openai/v2/models") + } + + @Test("Gemini reaches the model list under the base the user configured") + func geminiUsesTheResolvedBase() async throws { + StubModelListProtocol.respond(status: 200, body: #"{"models":[]}"#) + let provider = GeminiProvider( + endpoint: "https://generativelanguage.googleapis.com/v1beta", + apiKey: "key", + session: stubSession() + ) + _ = try await provider.fetchAvailableModels() + #expect( + StubModelListProtocol.lastRequestedURL() + == "https://generativelanguage.googleapis.com/v1beta/models" + ) + } +} diff --git a/TableProTests/Core/AI/LocalProviderRegistrationTests.swift b/TableProTests/Core/AI/LocalProviderRegistrationTests.swift index 23ff8d788a..5e8d3e541b 100644 --- a/TableProTests/Core/AI/LocalProviderRegistrationTests.swift +++ b/TableProTests/Core/AI/LocalProviderRegistrationTests.swift @@ -82,7 +82,12 @@ struct LocalProviderRegistrationTests { for type in [AIProviderType.llamaCpp, .mlx] { let config = AIProviderConfig(type: type) #expect(config.endpoint == "http://localhost:8080") - #expect(config.endpoint.openAIPath("chat/completions") == "http://localhost:8080/v1/chat/completions") + let style = type.endpointStyle + #expect(style == .chatCompletions) + #expect( + AIEndpoint(config.endpoint, style: style)?.chatURL(model: "local-model", style: style)?.absoluteString + == "http://localhost:8080/v1/chat/completions" + ) } } } diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderConnectionTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderConnectionTests.swift new file mode 100644 index 0000000000..b84c7ade89 --- /dev/null +++ b/TableProTests/Core/AI/OpenAICompatibleProviderConnectionTests.swift @@ -0,0 +1,148 @@ +// +// OpenAICompatibleProviderConnectionTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +private final class StubConnectionProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + nonisolated(unsafe) private static var status = 200 + nonisolated(unsafe) private static var body = Data() + nonisolated(unsafe) private static var requestedURLs: [String] = [] + + nonisolated(unsafe) private static var contentType = "application/json" + + static func respond(status: Int, body: String, contentType: String = "application/json") { + lock.lock(); defer { lock.unlock() } + Self.status = status + Self.body = Data(body.utf8) + Self.contentType = contentType + requestedURLs = [] + } + + static func lastRequestedURL() -> String? { + lock.lock(); defer { lock.unlock() } + return requestedURLs.last + } + + private static func record(_ url: String) { + lock.lock(); defer { lock.unlock() } + requestedURLs.append(url) + } + + private static func current() -> (status: Int, body: Data, contentType: String) { + lock.lock(); defer { lock.unlock() } + return (status, body, contentType) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + let response = Self.current() + guard let url = request.url, + let httpResponse = HTTPURLResponse( + url: url, statusCode: response.status, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": response.contentType] + ) + else { return } + Self.record(url.absoluteString) + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } +} + +@Suite("OpenAICompatibleProvider connection test", .serialized) +struct OpenAICompatibleProviderConnectionTests { + private func makeProvider(endpoint: String) -> OpenAICompatibleProvider { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubConnectionProtocol.self] + return OpenAICompatibleProvider( + endpoint: endpoint, + apiKey: "key", + providerType: .custom, + model: "glm-4.6", + session: URLSession(configuration: config) + ) + } + + @Test("A 200 is a working connection") + func acceptsOK() async throws { + StubConnectionProtocol.respond(status: 200, body: "{}") + #expect(try await makeProvider(endpoint: "https://host/v1").testConnection()) + } + + @Test("A 400 is a working connection, because the server answered the API") + func acceptsBadRequest() async throws { + StubConnectionProtocol.respond(status: 400, body: #"{"error":{"message":"bad param"}}"#) + #expect(try await makeProvider(endpoint: "https://host/v1").testConnection()) + } + + /// A 404 answered with a JSON error page used to read as success, so a wrong Base URL was + /// saved with a green "Connection successful". + @Test("A JSON 404 is a failure, not a success") + func rejectsJSONNotFound() async { + StubConnectionProtocol.respond( + status: 404, + body: #"{"timestamp":"2026-09-21T16:05:33.970+00:00","status":404,"error":"Not Found"}"# + ) + await #expect(throws: AIProviderError.self) { + _ = try await makeProvider(endpoint: "https://host/v1").testConnection() + } + } + + @Test("A JSON 500 is a failure, not a success") + func rejectsJSONServerError() async { + StubConnectionProtocol.respond(status: 500, body: #"{"error":{"message":"boom"}}"#) + await #expect(throws: AIProviderError.self) { + _ = try await makeProvider(endpoint: "https://host/v1").testConnection() + } + } + + @Test("A 401 reports an authentication failure") + func reportsAuthFailure() async { + StubConnectionProtocol.respond(status: 401, body: "{}") + await #expect(throws: AIProviderError.self) { + _ = try await makeProvider(endpoint: "https://host/v1").testConnection() + } + } + + @Test("An endpoint with no scheme reports the app's own invalid-endpoint error") + func reportsInvalidEndpoint() async { + StubConnectionProtocol.respond(status: 200, body: "{}") + await #expect(throws: AIProviderError.self) { + _ = try await makeProvider(endpoint: "api.z.ai/api/paas/v4").testConnection() + } + } + + /// A wrong Base URL that lands on a proxy login page or a single-page app's fallback route + /// answers 200 with HTML, which the chat stream cannot read. + @Test("An HTML 200 is not a working connection") + func rejectsHTMLSuccess() async throws { + StubConnectionProtocol.respond( + status: 200, + body: "Sign in", + contentType: "text/html; charset=utf-8" + ) + #expect(try await makeProvider(endpoint: "https://host/v1").testConnection() == false) + } + + @Test("A JSON body with no content type is still a working connection") + func acceptsJSONWithoutContentType() async throws { + StubConnectionProtocol.respond(status: 200, body: "{}", contentType: "text/plain") + #expect(try await makeProvider(endpoint: "https://host/v1").testConnection()) + } + + @Test("The connection test reaches the server's own version segment") + func callsTheResolvedURL() async throws { + StubConnectionProtocol.respond(status: 200, body: "{}") + _ = try await makeProvider(endpoint: "https://api.z.ai/api/paas/v4").testConnection() + #expect(StubConnectionProtocol.lastRequestedURL() == "https://api.z.ai/api/paas/v4/chat/completions") + } +} diff --git a/TableProTests/Core/AI/StringAIEndpointTests.swift b/TableProTests/Core/AI/StringAIEndpointTests.swift deleted file mode 100644 index 207ce29e86..0000000000 --- a/TableProTests/Core/AI/StringAIEndpointTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// StringAIEndpointTests.swift -// TableProTests -// -// Tests for AI endpoint path construction, including tolerance for base URLs -// that already include the /v1 version segment. -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("AI Endpoint Path") -struct StringAIEndpointTests { - @Test("base without version gets /v1 appended") - func appendsVersionWhenMissing() { - #expect("https://api.openai.com".openAIPath("chat/completions") == "https://api.openai.com/v1/chat/completions") - #expect("https://openrouter.ai/api".openAIPath("chat/completions") == "https://openrouter.ai/api/v1/chat/completions") - } - - @Test("base ending in /v1 is not doubled") - func doesNotDoubleVersion() { - #expect("https://opencode.ai/zen/v1".openAIPath("chat/completions") == "https://opencode.ai/zen/v1/chat/completions") - #expect("https://opencode.ai/zen/v1".openAIPath("models") == "https://opencode.ai/zen/v1/models") - } - - @Test("base without /v1 resolves the OpenCode Zen path") - func openCodeZenWithoutVersion() { - #expect("https://opencode.ai/zen".openAIPath("chat/completions") == "https://opencode.ai/zen/v1/chat/completions") - #expect("https://opencode.ai/zen".openAIPath("models") == "https://opencode.ai/zen/v1/models") - } - - @Test("trailing slash is normalized before building the path") - func normalizesTrailingSlash() { - #expect("https://opencode.ai/zen/v1/".openAIPath("models") == "https://opencode.ai/zen/v1/models") - #expect("https://api.openai.com/".openAIPath("models") == "https://api.openai.com/v1/models") - } -} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index dcb3141f04..71b58ade55 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -18,6 +18,9 @@ Open **Settings > AI** (`Cmd+,`). **Enable AI Features** at the top gates the wh Click **Add Provider…** and pick a type. **Add Custom Provider…** at the bottom takes any OpenAI-compatible endpoint. + + 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.