292 lines
11 KiB
Swift
292 lines
11 KiB
Swift
//
|
|
// ClaudeAPI.swift
|
|
// Claude API Implementation with streaming support
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Claude API helper with streaming for progressive text display.
|
|
class ClaudeAPI {
|
|
private static let tlsWarmupLock = NSLock()
|
|
private static var hasStartedTLSWarmup = false
|
|
|
|
private let apiURL: URL
|
|
var model: String
|
|
private let session: URLSession
|
|
|
|
init(proxyURL: String, model: String = "claude-sonnet-4-6") {
|
|
self.apiURL = URL(string: proxyURL)!
|
|
self.model = model
|
|
|
|
// Use .default instead of .ephemeral so TLS session tickets are cached.
|
|
// Ephemeral sessions do a full TLS handshake on every request, which causes
|
|
// transient -1200 (errSSLPeerHandshakeFail) errors with large image payloads.
|
|
// Disable URL/cookie caching to avoid storing responses or credentials on disk.
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 120
|
|
config.timeoutIntervalForResource = 300
|
|
config.waitsForConnectivity = true
|
|
config.urlCache = nil
|
|
config.httpCookieStorage = nil
|
|
self.session = URLSession(configuration: config)
|
|
|
|
// Fire a lightweight HEAD request in the background to pre-establish the TLS
|
|
// connection. This caches the TLS session ticket so the first real API call
|
|
// (which carries a large image payload) doesn't need a cold TLS handshake.
|
|
warmUpTLSConnectionIfNeeded()
|
|
}
|
|
|
|
private func makeAPIRequest() -> URLRequest {
|
|
var request = URLRequest(url: apiURL)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = 120
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
return request
|
|
}
|
|
|
|
/// Detects the MIME type of image data by inspecting the first bytes.
|
|
/// Screen captures from ScreenCaptureKit are JPEG, but pasted images from the
|
|
/// clipboard are PNG. The API rejects requests where the declared media_type
|
|
/// doesn't match the actual image format.
|
|
private func detectImageMediaType(for imageData: Data) -> String {
|
|
// PNG files start with the 8-byte signature: 89 50 4E 47 0D 0A 1A 0A
|
|
if imageData.count >= 4 {
|
|
let pngSignature: [UInt8] = [0x89, 0x50, 0x4E, 0x47]
|
|
let firstFourBytes = [UInt8](imageData.prefix(4))
|
|
if firstFourBytes == pngSignature {
|
|
return "image/png"
|
|
}
|
|
}
|
|
// Default to JPEG — screen captures use JPEG compression
|
|
return "image/jpeg"
|
|
}
|
|
|
|
/// Sends a no-op HEAD request to the API host to establish and cache a TLS session.
|
|
/// Failures are silently ignored — this is purely an optimization.
|
|
private func warmUpTLSConnectionIfNeeded() {
|
|
Self.tlsWarmupLock.lock()
|
|
let shouldStartTLSWarmup = !Self.hasStartedTLSWarmup
|
|
if shouldStartTLSWarmup {
|
|
Self.hasStartedTLSWarmup = true
|
|
}
|
|
Self.tlsWarmupLock.unlock()
|
|
|
|
guard shouldStartTLSWarmup else { return }
|
|
|
|
guard var warmupURLComponents = URLComponents(url: apiURL, resolvingAgainstBaseURL: false) else {
|
|
return
|
|
}
|
|
|
|
// The TLS session ticket is host-scoped, so warming the root host is enough.
|
|
// Hitting the host instead of `/v1/messages` avoids extra endpoint-specific noise.
|
|
warmupURLComponents.path = "/"
|
|
warmupURLComponents.query = nil
|
|
warmupURLComponents.fragment = nil
|
|
|
|
guard let warmupURL = warmupURLComponents.url else {
|
|
return
|
|
}
|
|
|
|
var warmupRequest = URLRequest(url: warmupURL)
|
|
warmupRequest.httpMethod = "HEAD"
|
|
warmupRequest.timeoutInterval = 10
|
|
session.dataTask(with: warmupRequest) { _, _, _ in
|
|
// Response doesn't matter — the TLS handshake is the goal
|
|
}.resume()
|
|
}
|
|
|
|
/// Send a vision request to Claude with streaming.
|
|
/// Calls `onTextChunk` on the main actor each time new text arrives so the UI updates progressively.
|
|
/// Returns the full accumulated text and total duration when the stream completes.
|
|
func analyzeImageStreaming(
|
|
images: [(data: Data, label: String)],
|
|
systemPrompt: String,
|
|
conversationHistory: [(userPlaceholder: String, assistantResponse: String)] = [],
|
|
userPrompt: String,
|
|
onTextChunk: @MainActor @Sendable (String) -> Void
|
|
) async throws -> (text: String, duration: TimeInterval) {
|
|
let startTime = Date()
|
|
|
|
var request = makeAPIRequest()
|
|
|
|
// Build messages array
|
|
var messages: [[String: Any]] = []
|
|
|
|
for (userPlaceholder, assistantResponse) in conversationHistory {
|
|
messages.append(["role": "user", "content": userPlaceholder])
|
|
messages.append(["role": "assistant", "content": assistantResponse])
|
|
}
|
|
|
|
// Build current message with all labeled images + prompt
|
|
var contentBlocks: [[String: Any]] = []
|
|
for image in images {
|
|
contentBlocks.append([
|
|
"type": "image",
|
|
"source": [
|
|
"type": "base64",
|
|
"media_type": detectImageMediaType(for: image.data),
|
|
"data": image.data.base64EncodedString()
|
|
]
|
|
])
|
|
contentBlocks.append([
|
|
"type": "text",
|
|
"text": image.label
|
|
])
|
|
}
|
|
contentBlocks.append([
|
|
"type": "text",
|
|
"text": userPrompt
|
|
])
|
|
messages.append(["role": "user", "content": contentBlocks])
|
|
|
|
let body: [String: Any] = [
|
|
"model": model,
|
|
"max_tokens": 1024,
|
|
"stream": true,
|
|
"system": systemPrompt,
|
|
"messages": messages
|
|
]
|
|
|
|
let bodyData = try JSONSerialization.data(withJSONObject: body)
|
|
request.httpBody = bodyData
|
|
let payloadMB = Double(bodyData.count) / 1_048_576.0
|
|
print("🌐 Claude streaming request: \(String(format: "%.1f", payloadMB))MB, \(images.count) image(s)")
|
|
|
|
// Use bytes streaming for SSE (Server-Sent Events)
|
|
let (byteStream, response) = try await session.bytes(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw NSError(
|
|
domain: "ClaudeAPI",
|
|
code: -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Invalid HTTP response"]
|
|
)
|
|
}
|
|
|
|
// If non-2xx status, read the full body as error text
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
var errorBodyChunks: [String] = []
|
|
for try await line in byteStream.lines {
|
|
errorBodyChunks.append(line)
|
|
}
|
|
let errorBody = errorBodyChunks.joined(separator: "\n")
|
|
throw NSError(
|
|
domain: "ClaudeAPI",
|
|
code: httpResponse.statusCode,
|
|
userInfo: [NSLocalizedDescriptionKey: "API Error (\(httpResponse.statusCode)): \(errorBody)"]
|
|
)
|
|
}
|
|
|
|
// Parse SSE stream — each event is "data: {json}\n\n"
|
|
var accumulatedResponseText = ""
|
|
|
|
for try await line in byteStream.lines {
|
|
// SSE lines look like: "data: {...}"
|
|
guard line.hasPrefix("data: ") else { continue }
|
|
let jsonString = String(line.dropFirst(6)) // Drop "data: " prefix
|
|
|
|
// End of stream marker
|
|
guard jsonString != "[DONE]" else { break }
|
|
|
|
guard let jsonData = jsonString.data(using: .utf8),
|
|
let eventPayload = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
|
let eventType = eventPayload["type"] as? String else {
|
|
continue
|
|
}
|
|
|
|
// We care about content_block_delta events that contain text chunks
|
|
if eventType == "content_block_delta",
|
|
let delta = eventPayload["delta"] as? [String: Any],
|
|
let deltaType = delta["type"] as? String,
|
|
deltaType == "text_delta",
|
|
let textChunk = delta["text"] as? String {
|
|
accumulatedResponseText += textChunk
|
|
// Send the accumulated text so far to the UI for progressive rendering
|
|
let currentAccumulatedText = accumulatedResponseText
|
|
await onTextChunk(currentAccumulatedText)
|
|
}
|
|
}
|
|
|
|
let duration = Date().timeIntervalSince(startTime)
|
|
return (text: accumulatedResponseText, duration: duration)
|
|
}
|
|
|
|
/// Non-streaming fallback for validation requests where we don't need progressive display.
|
|
func analyzeImage(
|
|
images: [(data: Data, label: String)],
|
|
systemPrompt: String,
|
|
conversationHistory: [(userPlaceholder: String, assistantResponse: String)] = [],
|
|
userPrompt: String
|
|
) async throws -> (text: String, duration: TimeInterval) {
|
|
let startTime = Date()
|
|
|
|
var request = makeAPIRequest()
|
|
|
|
var messages: [[String: Any]] = []
|
|
for (userPlaceholder, assistantResponse) in conversationHistory {
|
|
messages.append(["role": "user", "content": userPlaceholder])
|
|
messages.append(["role": "assistant", "content": assistantResponse])
|
|
}
|
|
|
|
// Build current message with all labeled images + prompt
|
|
var contentBlocks: [[String: Any]] = []
|
|
for image in images {
|
|
contentBlocks.append([
|
|
"type": "image",
|
|
"source": [
|
|
"type": "base64",
|
|
"media_type": detectImageMediaType(for: image.data),
|
|
"data": image.data.base64EncodedString()
|
|
]
|
|
])
|
|
contentBlocks.append([
|
|
"type": "text",
|
|
"text": image.label
|
|
])
|
|
}
|
|
contentBlocks.append([
|
|
"type": "text",
|
|
"text": userPrompt
|
|
])
|
|
messages.append(["role": "user", "content": contentBlocks])
|
|
|
|
let body: [String: Any] = [
|
|
"model": model,
|
|
"max_tokens": 256,
|
|
"system": systemPrompt,
|
|
"messages": messages
|
|
]
|
|
|
|
let bodyData = try JSONSerialization.data(withJSONObject: body)
|
|
request.httpBody = bodyData
|
|
let payloadMB = Double(bodyData.count) / 1_048_576.0
|
|
print("🌐 Claude request: \(String(format: "%.1f", payloadMB))MB, \(images.count) image(s)")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(200...299).contains(httpResponse.statusCode) else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw NSError(
|
|
domain: "ClaudeAPI",
|
|
code: (response as? HTTPURLResponse)?.statusCode ?? -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "API Error: \(responseString)"]
|
|
)
|
|
}
|
|
|
|
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
guard let content = json?["content"] as? [[String: Any]],
|
|
let textBlock = content.first(where: { ($0["type"] as? String) == "text" }),
|
|
let text = textBlock["text"] as? String else {
|
|
throw NSError(
|
|
domain: "ClaudeAPI",
|
|
code: -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]
|
|
)
|
|
}
|
|
|
|
let duration = Date().timeIntervalSince(startTime)
|
|
return (text: text, duration: duration)
|
|
}
|
|
}
|