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 d34d0f99..37f53ec5 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,66 @@ 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 + } + + if statusCode == 401 { + throw AIProviderError.authenticationFailed("") + } + + let body = String(data: data, encoding: .utf8) ?? "" + throw mapHTTPError(statusCode: statusCode, body: body) } // MARK: - Private @@ -209,21 +243,25 @@ 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 = AIProviderError.parseErrorMessage(from: body) ?? body + switch statusCode { + case 400: + return .serverError(statusCode, message) case 401: - return .authenticationFailed(body) + return .authenticationFailed(message) 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/GeminiProvider.swift b/TablePro/Core/AI/GeminiProvider.swift index 3205157b..136ddfb0 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,24 @@ 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 + 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: statusCode, body: body) + } + + return true } // MARK: - Private @@ -211,21 +222,23 @@ 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 mapHTTPError(statusCode: Int, body: String) -> AIProviderError { + let message = AIProviderError.parseErrorMessage(from: body) ?? body + switch statusCode { case 401, 403: - return .authenticationFailed(body) + return .authenticationFailed(message) 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..03d36f8f 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) @@ -327,21 +327,23 @@ 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 mapHTTPError(statusCode: Int, body: String) -> AIProviderError { + let message = AIProviderError.parseErrorMessage(from: body) ?? body + switch statusCode { case 401: - return .authenticationFailed(body) + return .authenticationFailed(message) 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