// // StreamingService.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation /// One Server-Sent Event as parsed off the wire. struct SSEEvent { /// The `event:` field, if the stream names its events (Anthropic does). var event: String? /// Joined `data:` lines. var data: String } /// Incremental SSE parser. Feed it raw lines (or byte chunks split on newlines) /// and it yields complete events at blank-line boundaries, ignoring `:` comment /// lines (DeepSeek sends `: keep-alive`) and unknown fields. struct SSEParser { private var currentEvent: String? private var currentData: [String] = [] /// Consumes one line (without its trailing newline). Returns a completed /// event when the line is the blank separator, else nil. mutating func consume(line: String) -> SSEEvent? { if line.isEmpty { guard !currentData.isEmpty || currentEvent != nil else { return nil } let event = SSEEvent(event: currentEvent, data: currentData.joined(separator: "\n")) currentEvent = nil currentData = [] return event.data.isEmpty && event.event == nil ? nil : event } if line.hasPrefix(":") { return nil } // comment / keep-alive if line.hasPrefix("event:") { currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) } else if line.hasPrefix("data:") { var value = String(line.dropFirst(5)) if value.hasPrefix(" ") { value.removeFirst() } currentData.append(value) } // id:/retry:/unknown fields are ignored. return nil } } /// Shared networking for all provider clients: request construction helpers and /// an SSE line stream over URLSession. enum StreamingService { /// URLSession tuned for long-lived streaming responses. static let session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 120 config.timeoutIntervalForResource = 900 config.httpAdditionalHeaders = ["User-Agent": "ZyquoCloud/1.0 (macOS)"] return URLSession(configuration: config) }() /// POSTs `body` as JSON and returns the SSE events of the response. /// Throws `ProviderError` on non-2xx status (reading the full error body). static func sseEvents( for request: URLRequest, provider: ProviderID ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { do { let (bytes, response) = try await session.bytes(for: request) guard let http = response as? HTTPURLResponse else { throw ProviderError.invalidResponse(provider, detail: "not an HTTP response") } guard (200..<300).contains(http.statusCode) else { var body = Data() for try await byte in bytes { body.append(byte) } throw ProviderError.from(status: http.statusCode, body: body, provider: provider) } // NOTE: AsyncBytes.lines skips empty lines, which are the // SSE event separators — split manually to preserve them. var parser = SSEParser() var lineBuffer = Data() for try await byte in bytes { if Task.isCancelled { break } if byte == 0x0A { // \n if lineBuffer.last == 0x0D { lineBuffer.removeLast() } // \r\n let line = String(decoding: lineBuffer, as: UTF8.self) lineBuffer.removeAll(keepingCapacity: true) if let event = parser.consume(line: line) { continuation.yield(event) } } else { lineBuffer.append(byte) } } // Flush a trailing line + event if the stream ended // without a final newline / blank separator. if !lineBuffer.isEmpty { let line = String(decoding: lineBuffer, as: UTF8.self) if let event = parser.consume(line: line) { continuation.yield(event) } } if let event = parser.consume(line: "") { continuation.yield(event) } continuation.finish() } catch is CancellationError { continuation.finish(throwing: ProviderError.cancelled) } catch let error as ProviderError { continuation.finish(throwing: error) } catch { continuation.finish(throwing: ProviderError.networkError(underlying: error)) } } continuation.onTermination = { _ in task.cancel() } } } /// Non-streaming JSON POST with exponential backoff on 429/5xx (3 attempts). /// Returns the response body data. static func postJSON( _ request: URLRequest, provider: ProviderID ) async throws -> Data { let maxAttempts = 3 var lastError: ProviderError = .invalidResponse(provider, detail: "no attempts made") for attempt in 1...maxAttempts { do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw ProviderError.invalidResponse(provider, detail: "not an HTTP response") } guard (200..<300).contains(http.statusCode) else { let error = ProviderError.from(status: http.statusCode, body: data, provider: provider) if attempt < maxAttempts, http.statusCode == 429 || http.statusCode >= 500 { lastError = error let retryAfter = (response as? HTTPURLResponse)? .value(forHTTPHeaderField: "Retry-After").flatMap(Double.init) let delay = retryAfter ?? pow(2, Double(attempt)) * 2 // 4s, 8s try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) continue } throw error } return data } catch let error as ProviderError { throw error } catch is CancellationError { throw ProviderError.cancelled } catch { throw ProviderError.networkError(underlying: error) } } throw lastError } /// GET returning decoded JSON data, with the same error mapping. static func getJSON( _ request: URLRequest, provider: ProviderID ) async throws -> Data { try await postJSON(request, provider: provider) } }