Skip to content

Commit 28ce250

Browse files
authored
fix: improve AI provider connection test error handling (#407)
* fix: improve AI provider connection test error handling * refactor: deduplicate parseErrorMessage, preserve 401 detail for streaming, fix body.count
1 parent 915d75d commit 28ce250

File tree

5 files changed

+100
-29
lines changed

5 files changed

+100
-29
lines changed

TablePro/Core/AI/AIProvider.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,17 @@ enum AIProviderError: Error, LocalizedError {
5454
return String(localized: "Streaming failed: \(message)")
5555
}
5656
}
57+
58+
/// Extract human-readable message from provider JSON error responses.
59+
/// Supports Anthropic (`{"error":{"message":"..."}}`), OpenAI, and Gemini formats.
60+
static func parseErrorMessage(from body: String) -> String? {
61+
guard let data = body.data(using: .utf8),
62+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
63+
let error = json["error"] as? [String: Any],
64+
let message = error["message"] as? String
65+
else {
66+
return nil
67+
}
68+
return message
69+
}
5770
}

TablePro/Core/AI/AnthropicProvider.swift

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ final class AnthropicProvider: AIProvider {
1818

1919
init(endpoint: String, apiKey: String) {
2020
self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
21-
self.apiKey = apiKey
21+
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
2222
self.session = URLSession(configuration: .ephemeral)
2323
}
2424

@@ -94,32 +94,66 @@ final class AnthropicProvider: AIProvider {
9494
}
9595

9696
func fetchAvailableModels() async throws -> [String] {
97-
// Anthropic doesn't have a models endpoint; return known models
98-
[
99-
"claude-sonnet-4-5-20250514",
100-
"claude-haiku-4-5-20251001",
101-
"claude-opus-4-20250514"
102-
]
97+
guard let url = URL(string: "\(endpoint)/v1/models") else {
98+
throw AIProviderError.invalidEndpoint(endpoint)
99+
}
100+
101+
var request = URLRequest(url: url)
102+
request.httpMethod = "GET"
103+
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
104+
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
105+
106+
let (data, response) = try await session.data(for: request)
107+
108+
guard let httpResponse = response as? HTTPURLResponse,
109+
httpResponse.statusCode == 200,
110+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
111+
let models = json["data"] as? [[String: Any]]
112+
else {
113+
return Self.knownModels
114+
}
115+
116+
let modelIds = models.compactMap { $0["id"] as? String }
117+
return modelIds.isEmpty ? Self.knownModels : modelIds
103118
}
104119

120+
private static let knownModels = [
121+
"claude-sonnet-4-6",
122+
"claude-opus-4-6",
123+
"claude-haiku-4-5-20251001",
124+
"claude-sonnet-4-5-20250929",
125+
"claude-opus-4-5-20251101"
126+
]
127+
105128
func testConnection() async throws -> Bool {
106129
let testMessage = AIChatMessage(role: .user, content: "Hi")
107130
let request = try buildMessagesRequest(
108131
messages: [testMessage],
109-
model: "claude-sonnet-4-5-20250514",
132+
model: "claude-haiku-4-5-20251001",
110133
systemPrompt: nil,
111134
maxTokens: 1,
112135
stream: false
113136
)
114137

115-
let (_, response) = try await session.data(for: request)
138+
let (data, response) = try await session.data(for: request)
116139

117140
guard let httpResponse = response as? HTTPURLResponse else {
118141
return false
119142
}
120143

121-
// 200 = success, 401 = bad key
122-
return httpResponse.statusCode == 200
144+
let statusCode = httpResponse.statusCode
145+
146+
// 200 = full success, 400 = key is valid but request was rejected (e.g. billing)
147+
if statusCode == 200 || statusCode == 400 {
148+
return true
149+
}
150+
151+
if statusCode == 401 {
152+
throw AIProviderError.authenticationFailed("")
153+
}
154+
155+
let body = String(data: data, encoding: .utf8) ?? ""
156+
throw mapHTTPError(statusCode: statusCode, body: body)
123157
}
124158

125159
// MARK: - Private
@@ -209,21 +243,25 @@ final class AnthropicProvider: AIProvider {
209243
var body = ""
210244
for try await line in bytes.lines {
211245
body += line
212-
if body.count > 2_000 { break }
246+
if (body as NSString).length > 2_000 { break }
213247
}
214248
return body
215249
}
216250

217251
private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
252+
let message = AIProviderError.parseErrorMessage(from: body) ?? body
253+
218254
switch statusCode {
255+
case 400:
256+
return .serverError(statusCode, message)
219257
case 401:
220-
return .authenticationFailed(body)
258+
return .authenticationFailed(message)
221259
case 429:
222260
return .rateLimited
223261
case 404:
224-
return .modelNotFound(body)
262+
return .modelNotFound(message)
225263
default:
226-
return .serverError(statusCode, body)
264+
return .serverError(statusCode, message)
227265
}
228266
}
229267
}

TablePro/Core/AI/GeminiProvider.swift

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ final class GeminiProvider: AIProvider {
1818

1919
init(endpoint: String, apiKey: String) {
2020
self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
21-
self.apiKey = apiKey
21+
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
2222
self.session = URLSession(configuration: .ephemeral)
2323
}
2424

@@ -154,13 +154,24 @@ final class GeminiProvider: AIProvider {
154154
var request = URLRequest(url: url)
155155
request.httpMethod = "GET"
156156

157-
let (_, response) = try await session.data(for: request)
157+
let (data, response) = try await session.data(for: request)
158158

159159
guard let httpResponse = response as? HTTPURLResponse else {
160160
return false
161161
}
162162

163-
return httpResponse.statusCode == 200
163+
let statusCode = httpResponse.statusCode
164+
165+
if statusCode == 401 || statusCode == 403 {
166+
throw AIProviderError.authenticationFailed("")
167+
}
168+
169+
guard statusCode == 200 else {
170+
let body = String(data: data, encoding: .utf8) ?? ""
171+
throw mapHTTPError(statusCode: statusCode, body: body)
172+
}
173+
174+
return true
164175
}
165176

166177
// MARK: - Private
@@ -211,21 +222,23 @@ final class GeminiProvider: AIProvider {
211222
var body = ""
212223
for try await line in bytes.lines {
213224
body += line
214-
if body.count > 2_000 { break }
225+
if (body as NSString).length > 2_000 { break }
215226
}
216227
return body
217228
}
218229

219230
private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
231+
let message = AIProviderError.parseErrorMessage(from: body) ?? body
232+
220233
switch statusCode {
221234
case 401, 403:
222-
return .authenticationFailed(body)
235+
return .authenticationFailed(message)
223236
case 429:
224237
return .rateLimited
225238
case 404:
226-
return .modelNotFound(body)
239+
return .modelNotFound(message)
227240
default:
228-
return .serverError(statusCode, body)
241+
return .serverError(statusCode, message)
229242
}
230243
}
231244
}

TablePro/Core/AI/OpenAICompatibleProvider.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ final class OpenAICompatibleProvider: AIProvider {
2222

2323
init(endpoint: String, apiKey: String?, providerType: AIProviderType) {
2424
self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
25-
self.apiKey = apiKey
25+
self.apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines)
2626
self.providerType = providerType
2727
self.session = URLSession(configuration: .ephemeral)
2828
}
@@ -147,7 +147,7 @@ final class OpenAICompatibleProvider: AIProvider {
147147
let isJSON = contentType.contains("application/json")
148148

149149
if httpResponse.statusCode == 401 {
150-
return false
150+
throw AIProviderError.authenticationFailed("")
151151
}
152152

153153
// Non-JSON response means wrong endpoint (e.g., HTML 404 page)
@@ -327,21 +327,23 @@ final class OpenAICompatibleProvider: AIProvider {
327327
var body = ""
328328
for try await line in bytes.lines {
329329
body += line
330-
if body.count > 2_000 { break }
330+
if (body as NSString).length > 2_000 { break }
331331
}
332332
return body
333333
}
334334

335335
private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
336+
let message = AIProviderError.parseErrorMessage(from: body) ?? body
337+
336338
switch statusCode {
337339
case 401:
338-
return .authenticationFailed(body)
340+
return .authenticationFailed(message)
339341
case 429:
340342
return .rateLimited
341343
case 404:
342-
return .modelNotFound(body)
344+
return .modelNotFound(message)
343345
default:
344-
return .serverError(statusCode, body)
346+
return .serverError(statusCode, message)
345347
}
346348
}
347349
}

TablePro/Views/Settings/AISettingsView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ private struct AIProviderEditorSheet: View {
472472
Text("Test")
473473
}
474474
}
475-
.disabled(isTesting)
475+
.disabled(isTesting || (draft.type.requiresAPIKey && editingAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
476476

477477
if case .success = testResult {
478478
Text(String(localized: "Connection successful"))
@@ -547,6 +547,11 @@ private struct AIProviderEditorSheet: View {
547547
// MARK: - Connection Test
548548

549549
func testProvider() {
550+
guard !editingAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !draft.type.requiresAPIKey else {
551+
testResult = .failure(String(localized: "API key is required"))
552+
return
553+
}
554+
550555
let provider = AIProviderFactory.createProvider(for: draft, apiKey: editingAPIKey)
551556

552557
isTesting = true

0 commit comments

Comments
 (0)