From 07619cb0df4a4dbb2be8787194e56c0ba9097b6d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 01:32:32 +0700 Subject: [PATCH 1/2] fix: improve AI provider connection test error handling --- TablePro/Core/AI/AnthropicProvider.swift | 76 +++++++++++++++---- TablePro/Core/AI/GeminiProvider.swift | 30 ++++++-- .../Core/AI/OpenAICompatibleProvider.swift | 23 ++++-- TablePro/Views/Settings/AISettingsView.swift | 7 +- 4 files changed, 110 insertions(+), 26 deletions(-) diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index d34d0f99..58939f0c 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -18,7 +18,7 @@ final class AnthropicProvider: AIProvider { init(endpoint: String, apiKey: String) { self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint - self.apiKey = apiKey + self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) self.session = URLSession(configuration: .ephemeral) } @@ -94,32 +94,62 @@ final class AnthropicProvider: AIProvider { } func fetchAvailableModels() async throws -> [String] { - // Anthropic doesn't have a models endpoint; return known models - [ - "claude-sonnet-4-5-20250514", - "claude-haiku-4-5-20251001", - "claude-opus-4-20250514" - ] + guard let url = URL(string: "\(endpoint)/v1/models") else { + throw AIProviderError.invalidEndpoint(endpoint) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let models = json["data"] as? [[String: Any]] + else { + return Self.knownModels + } + + let modelIds = models.compactMap { $0["id"] as? String } + return modelIds.isEmpty ? Self.knownModels : modelIds } + private static let knownModels = [ + "claude-sonnet-4-6", + "claude-opus-4-6", + "claude-haiku-4-5-20251001", + "claude-sonnet-4-5-20250929", + "claude-opus-4-5-20251101" + ] + func testConnection() async throws -> Bool { let testMessage = AIChatMessage(role: .user, content: "Hi") let request = try buildMessagesRequest( messages: [testMessage], - model: "claude-sonnet-4-5-20250514", + model: "claude-haiku-4-5-20251001", systemPrompt: nil, maxTokens: 1, stream: false ) - let (_, response) = try await session.data(for: request) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { return false } - // 200 = success, 401 = bad key - return httpResponse.statusCode == 200 + let statusCode = httpResponse.statusCode + + // 200 = full success, 400 = key is valid but request was rejected (e.g. billing) + if statusCode == 200 || statusCode == 400 { + return true + } + + let body = String(data: data, encoding: .utf8) ?? "" + throw mapHTTPError(statusCode: statusCode, body: body) } // MARK: - Private @@ -215,15 +245,33 @@ final class AnthropicProvider: AIProvider { } private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError { + let message = parseErrorMessage(body) ?? body + switch statusCode { + case 400: + // Billing/credits errors return 400 + return .serverError(statusCode, message) case 401: - return .authenticationFailed(body) + // Show a clear message instead of raw API error like "x-api-key header is required" + return .authenticationFailed("") case 429: return .rateLimited case 404: - return .modelNotFound(body) + return .modelNotFound(message) default: - return .serverError(statusCode, body) + return .serverError(statusCode, message) + } + } + + /// Extract human-readable message from Anthropic's JSON error response + private func parseErrorMessage(_ body: String) -> String? { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String + else { + return nil } + return message } } diff --git a/TablePro/Core/AI/GeminiProvider.swift b/TablePro/Core/AI/GeminiProvider.swift index 3205157b..1e16efc4 100644 --- a/TablePro/Core/AI/GeminiProvider.swift +++ b/TablePro/Core/AI/GeminiProvider.swift @@ -18,7 +18,7 @@ final class GeminiProvider: AIProvider { init(endpoint: String, apiKey: String) { self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint - self.apiKey = apiKey + self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) self.session = URLSession(configuration: .ephemeral) } @@ -154,13 +154,18 @@ final class GeminiProvider: AIProvider { var request = URLRequest(url: url) request.httpMethod = "GET" - let (_, response) = try await session.data(for: request) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { return false } - return httpResponse.statusCode == 200 + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + throw mapHTTPError(statusCode: httpResponse.statusCode, body: body) + } + + return true } // MARK: - Private @@ -216,16 +221,29 @@ final class GeminiProvider: AIProvider { return body } + private func parseErrorMessage(_ body: String) -> String? { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String + else { + return nil + } + return message + } + private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError { + let message = parseErrorMessage(body) ?? body + switch statusCode { case 401, 403: - return .authenticationFailed(body) + return .authenticationFailed("") case 429: return .rateLimited case 404: - return .modelNotFound(body) + return .modelNotFound(message) default: - return .serverError(statusCode, body) + return .serverError(statusCode, message) } } } diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index ca2e3ea5..88120e0f 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -22,7 +22,7 @@ final class OpenAICompatibleProvider: AIProvider { init(endpoint: String, apiKey: String?, providerType: AIProviderType) { self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint - self.apiKey = apiKey + self.apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) self.providerType = providerType self.session = URLSession(configuration: .ephemeral) } @@ -147,7 +147,7 @@ final class OpenAICompatibleProvider: AIProvider { let isJSON = contentType.contains("application/json") if httpResponse.statusCode == 401 { - return false + throw AIProviderError.authenticationFailed("") } // Non-JSON response means wrong endpoint (e.g., HTML 404 page) @@ -332,16 +332,29 @@ final class OpenAICompatibleProvider: AIProvider { return body } + private func parseErrorMessage(_ body: String) -> String? { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String + else { + return nil + } + return message + } + private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError { + let message = parseErrorMessage(body) ?? body + switch statusCode { case 401: - return .authenticationFailed(body) + return .authenticationFailed("") case 429: return .rateLimited case 404: - return .modelNotFound(body) + return .modelNotFound(message) default: - return .serverError(statusCode, body) + return .serverError(statusCode, message) } } } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 07932ef8..6a965a9f 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -472,7 +472,7 @@ private struct AIProviderEditorSheet: View { Text("Test") } } - .disabled(isTesting) + .disabled(isTesting || (draft.type.requiresAPIKey && editingAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) if case .success = testResult { Text(String(localized: "Connection successful")) @@ -547,6 +547,11 @@ private struct AIProviderEditorSheet: View { // MARK: - Connection Test func testProvider() { + guard !editingAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !draft.type.requiresAPIKey else { + testResult = .failure(String(localized: "API key is required")) + return + } + let provider = AIProviderFactory.createProvider(for: draft, apiKey: editingAPIKey) isTesting = true From 85db3b118502aaac0ccc3bd9046300ae9ee934d1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 22 Mar 2026 01:37:18 +0700 Subject: [PATCH 2/2] refactor: deduplicate parseErrorMessage, preserve 401 detail for streaming, fix body.count --- TablePro/Core/AI/AIProvider.swift | 13 +++++++++ TablePro/Core/AI/AnthropicProvider.swift | 24 +++++------------ TablePro/Core/AI/GeminiProvider.swift | 27 ++++++++----------- .../Core/AI/OpenAICompatibleProvider.swift | 17 +++--------- 4 files changed, 34 insertions(+), 47 deletions(-) diff --git a/TablePro/Core/AI/AIProvider.swift b/TablePro/Core/AI/AIProvider.swift index 49051dce..0776d4f3 100644 --- a/TablePro/Core/AI/AIProvider.swift +++ b/TablePro/Core/AI/AIProvider.swift @@ -54,4 +54,17 @@ enum AIProviderError: Error, LocalizedError { return String(localized: "Streaming failed: \(message)") } } + + /// Extract human-readable message from provider JSON error responses. + /// Supports Anthropic (`{"error":{"message":"..."}}`), OpenAI, and Gemini formats. + static func parseErrorMessage(from body: String) -> String? { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String + else { + return nil + } + return message + } } diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index 58939f0c..37f53ec5 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -148,6 +148,10 @@ final class AnthropicProvider: AIProvider { return true } + if statusCode == 401 { + throw AIProviderError.authenticationFailed("") + } + let body = String(data: data, encoding: .utf8) ?? "" throw mapHTTPError(statusCode: statusCode, body: body) } @@ -239,21 +243,19 @@ final class AnthropicProvider: AIProvider { var body = "" for try await line in bytes.lines { body += line - if body.count > 2_000 { break } + if (body as NSString).length > 2_000 { break } } return body } private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError { - let message = parseErrorMessage(body) ?? body + let message = AIProviderError.parseErrorMessage(from: body) ?? body switch statusCode { case 400: - // Billing/credits errors return 400 return .serverError(statusCode, message) case 401: - // Show a clear message instead of raw API error like "x-api-key header is required" - return .authenticationFailed("") + return .authenticationFailed(message) case 429: return .rateLimited case 404: @@ -262,16 +264,4 @@ final class AnthropicProvider: AIProvider { return .serverError(statusCode, message) } } - - /// Extract human-readable message from Anthropic's JSON error response - private func parseErrorMessage(_ body: String) -> String? { - guard let data = body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let error = json["error"] as? [String: Any], - let message = error["message"] as? String - else { - return nil - } - return message - } } diff --git a/TablePro/Core/AI/GeminiProvider.swift b/TablePro/Core/AI/GeminiProvider.swift index 1e16efc4..136ddfb0 100644 --- a/TablePro/Core/AI/GeminiProvider.swift +++ b/TablePro/Core/AI/GeminiProvider.swift @@ -160,9 +160,15 @@ final class GeminiProvider: AIProvider { return false } - guard httpResponse.statusCode == 200 else { + let statusCode = httpResponse.statusCode + + if statusCode == 401 || statusCode == 403 { + throw AIProviderError.authenticationFailed("") + } + + guard statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "" - throw mapHTTPError(statusCode: httpResponse.statusCode, body: body) + throw mapHTTPError(statusCode: statusCode, body: body) } return true @@ -216,28 +222,17 @@ final class GeminiProvider: AIProvider { var body = "" for try await line in bytes.lines { body += line - if body.count > 2_000 { break } + if (body as NSString).length > 2_000 { break } } return body } - private func parseErrorMessage(_ body: String) -> String? { - guard let data = body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let error = json["error"] as? [String: Any], - let message = error["message"] as? String - else { - return nil - } - return message - } - private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError { - let message = parseErrorMessage(body) ?? body + let message = AIProviderError.parseErrorMessage(from: body) ?? body switch statusCode { case 401, 403: - return .authenticationFailed("") + return .authenticationFailed(message) case 429: return .rateLimited case 404: diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 88120e0f..03d36f8f 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -327,28 +327,17 @@ final class OpenAICompatibleProvider: AIProvider { var body = "" for try await line in bytes.lines { body += line - if body.count > 2_000 { break } + if (body as NSString).length > 2_000 { break } } return body } - private func parseErrorMessage(_ body: String) -> String? { - guard let data = body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let error = json["error"] as? [String: Any], - let message = error["message"] as? String - else { - return nil - } - return message - } - private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError { - let message = parseErrorMessage(body) ?? body + let message = AIProviderError.parseErrorMessage(from: body) ?? body switch statusCode { case 401: - return .authenticationFailed("") + return .authenticationFailed(message) case 429: return .rateLimited case 404: