import Foundation struct APIConfiguration: Sendable { var baseURL: URL var authToken: String? } struct AnyEncodable: Encodable { private let encodeClosure: (Encoder) throws -> Void init(_ value: T) { encodeClosure = value.encode(to:) } func encode(to encoder: Encoder) throws { try encodeClosure(encoder) } } actor SybilAPIClient { private let configuration: APIConfiguration private let session: URLSession @MainActor private static let iso8601FormatterWithFractional: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() @MainActor private static let iso8601Formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] return formatter }() init(configuration: APIConfiguration, session: URLSession = .shared) { self.configuration = configuration self.session = session } func verifySession() async throws -> AuthSession { try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self) } func listChats() async throws -> [ChatSummary] { let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self) return response.chats } func createChat(title: String? = nil) async throws -> ChatSummary { let response = try await request( "/v1/chats", method: "POST", body: AnyEncodable(ChatCreateBody(title: title)), responseType: ChatCreateResponse.self ) return response.chat } func getChat(chatID: String) async throws -> ChatDetail { let response = try await request("/v1/chats/\(chatID)", method: "GET", responseType: ChatDetailResponse.self) return response.chat } func deleteChat(chatID: String) async throws { _ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self) } func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary { let response = try await request( "/v1/chats/title/suggest", method: "POST", body: AnyEncodable(SuggestTitleBody(chatId: chatID, content: content)), responseType: ChatCreateResponse.self ) return response.chat } func listSearches() async throws -> [SearchSummary] { let response = try await request("/v1/searches", method: "GET", responseType: SearchListResponse.self) return response.searches } func createSearch(title: String? = nil, query: String? = nil) async throws -> SearchSummary { let response = try await request( "/v1/searches", method: "POST", body: AnyEncodable(SearchCreateBody(title: title, query: query)), responseType: SearchCreateResponse.self ) return response.search } func getSearch(searchID: String) async throws -> SearchDetail { let response = try await request("/v1/searches/\(searchID)", method: "GET", responseType: SearchDetailResponse.self) return response.search } func createChatFromSearch(searchID: String, title: String? = nil) async throws -> ChatSummary { let response = try await request( "/v1/searches/\(searchID)/chat", method: "POST", body: AnyEncodable(SearchChatCreateBody(title: title)), responseType: ChatCreateResponse.self ) return response.chat } func deleteSearch(searchID: String) async throws { _ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self) } func listModels() async throws -> ModelCatalogResponse { try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self) } func runCompletionStream( body: CompletionStreamRequest, onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void ) async throws { let request = try makeRequest( path: "/v1/chat-completions/stream", method: "POST", body: AnyEncodable(body), acceptsSSE: true ) SybilLog.info( SybilLog.network, "Starting chat stream POST \(request.url?.absoluteString ?? "")" ) try await stream(request: request) { eventName, dataText in switch eventName { case "meta": let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName) await onEvent(.meta(payload)) case "tool_call": let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName) await onEvent(.toolCall(payload)) case "delta": let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName) await onEvent(.delta(payload)) case "done": do { let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName) await onEvent(.done(payload)) } catch { if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) { SybilLog.warning( SybilLog.network, "Recovered chat stream done payload from concatenated SSE data" ) await onEvent(.done(recovered)) } else { throw error } } case "error": let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName) await onEvent(.error(payload)) default: SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'") await onEvent(.ignored) } } SybilLog.info(SybilLog.network, "Chat stream completed") } func runSearchStream( searchID: String, body: SearchRunRequest, onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void ) async throws { let request = try makeRequest( path: "/v1/searches/\(searchID)/run/stream", method: "POST", body: AnyEncodable(body), acceptsSSE: true ) SybilLog.info( SybilLog.network, "Starting search stream POST \(request.url?.absoluteString ?? "")" ) try await stream(request: request) { eventName, dataText in switch eventName { case "search_results": let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName) await onEvent(.searchResults(payload)) case "search_error": let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName) await onEvent(.searchError(payload)) case "answer": let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName) await onEvent(.answer(payload)) case "answer_error": let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName) await onEvent(.answerError(payload)) case "done": let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName) await onEvent(.done(payload)) case "error": let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName) await onEvent(.error(payload)) default: SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'") await onEvent(.ignored) } } SybilLog.info(SybilLog.network, "Search stream completed") } private func request( _ path: String, method: String, body: AnyEncodable? = nil, responseType: Response.Type ) async throws -> Response { let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false) SybilLog.debug( SybilLog.network, "HTTP request \(method) \(request.url?.absoluteString ?? "")" ) let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { if let urlError = error as? URLError, urlError.code == .cancelled { SybilLog.debug( SybilLog.network, "HTTP request cancelled \(method) \(request.url?.absoluteString ?? "")" ) throw CancellationError() } let wrapped = Self.wrapTransportError(error, method: method, url: request.url) SybilLog.error(SybilLog.network, "HTTP transport failure", error: wrapped) throw wrapped } try validate(response: response, data: data) do { let decoded = try Self.decodeJSON(Response.self, from: data) if let httpResponse = response as? HTTPURLResponse { SybilLog.debug( SybilLog.network, "HTTP response \(httpResponse.statusCode) for \(method) \(request.url?.path(percentEncoded: false) ?? path)" ) } return decoded } catch let decodingError as DecodingError { let details = SybilLog.describe(decodingError) let snippet = Self.responseSnippet(data) let message = "Failed to decode response for \(method) \(request.url?.absoluteString ?? path): \(details). Body: \(snippet)" SybilLog.error(SybilLog.network, message) throw APIError.decodingError(message: message) } catch { SybilLog.error(SybilLog.network, "Unexpected decoding failure", error: error) throw error } } private func makeRequest( path: String, method: String, body: AnyEncodable?, acceptsSSE: Bool ) throws -> URLRequest { let url = try buildURL(path: path) var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 120 if acceptsSSE { request.setValue("text/event-stream", forHTTPHeaderField: "Accept") } else { request.setValue("application/json", forHTTPHeaderField: "Accept") } if let token = configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try Self.encodeJSON(body) } return request } private func buildURL(path: String) throws -> URL { guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else { throw APIError.invalidBaseURL } let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) var basePath = components.path if basePath.hasSuffix("/") { basePath.removeLast() } components.path = "\(basePath)/\(trimmedPath)" guard let url = components.url else { throw APIError.invalidBaseURL } return url } private func validate(response: URLResponse, data: Data) throws { guard let httpResponse = response as? HTTPURLResponse else { throw APIError.invalidResponse } guard (200 ... 299).contains(httpResponse.statusCode) else { let message = Self.parseMessage(from: data) ?? "\(httpResponse.statusCode) \(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode))" SybilLog.warning( SybilLog.network, "HTTP non-success status \(httpResponse.statusCode): \(message)" ) throw APIError.httpError(statusCode: httpResponse.statusCode, message: message) } } private static func parseMessage(from data: Data) -> String? { guard !data.isEmpty else { return nil } if let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let message = decoded["message"] as? String, !message.isEmpty { return message } return nil } private func stream( request: URLRequest, onEvent: @escaping @Sendable (_ eventName: String, _ dataText: String) async throws -> Void ) async throws { let bytes: URLSession.AsyncBytes let response: URLResponse do { (bytes, response) = try await session.bytes(for: request) } catch { if let urlError = error as? URLError, urlError.code == .cancelled { SybilLog.debug( SybilLog.network, "SSE request cancelled \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")" ) throw CancellationError() } let wrapped = Self.wrapTransportError(error, method: request.httpMethod ?? "GET", url: request.url) SybilLog.error(SybilLog.network, "SSE transport failure", error: wrapped) throw wrapped } try validate(response: response, data: Data()) var eventName = "message" var dataLines: [String] = [] var lineBytes: [UInt8] = [] for try await byte in bytes { if Task.isCancelled { SybilLog.warning(SybilLog.network, "SSE task cancelled") throw CancellationError() } if byte == 0x0A { // \n var bytesForLine = lineBytes if bytesForLine.last == 0x0D { // \r bytesForLine.removeLast() } let line = String(decoding: bytesForLine, as: UTF8.self) lineBytes.removeAll(keepingCapacity: true) if line.isEmpty { if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) { SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)") try await onEvent(emitted.name, emitted.payload) } continue } if line.hasPrefix(":") { continue } if line.hasPrefix("event:") { if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) { SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)") try await onEvent(emitted.name, emitted.payload) } eventName = line.dropFirst("event:".count).trimmingCharacters(in: .whitespaces) continue } if line.hasPrefix("data:") { let payload = line.dropFirst("data:".count) dataLines.append(Self.trimLeadingWhitespace(payload)) continue } SybilLog.debug(SybilLog.network, "Ignoring SSE line '\(String(line.prefix(120)))'") } else { lineBytes.append(byte) } } if !lineBytes.isEmpty { var bytesForLine = lineBytes if bytesForLine.last == 0x0D { // \r bytesForLine.removeLast() } let line = String(decoding: bytesForLine, as: UTF8.self) if line.isEmpty { if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) { SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)") try await onEvent(emitted.name, emitted.payload) } } else if line.hasPrefix("event:") { if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) { SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)") try await onEvent(emitted.name, emitted.payload) } eventName = line.dropFirst("event:".count).trimmingCharacters(in: .whitespaces) } else if line.hasPrefix("data:") { let payload = line.dropFirst("data:".count) dataLines.append(Self.trimLeadingWhitespace(payload)) } else if !line.hasPrefix(":") { SybilLog.debug(SybilLog.network, "Ignoring SSE line '\(String(line.prefix(120)))'") } } if let emitted = Self.flushSSEEvent(eventName: &eventName, dataLines: &dataLines) { SybilLog.debug(SybilLog.network, "SSE event \(emitted.name) payload chars=\(emitted.payload.count)") try await onEvent(emitted.name, emitted.payload) } } private static func decodeJSON(_ type: T.Type, from data: Data) throws -> T { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let string = try container.decode(String.self) if let value = Self.iso8601FormatterWithFractional.date(from: string) { return value } if let value = Self.iso8601Formatter.date(from: string) { return value } throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected ISO-8601 date") } return try decoder.decode(T.self, from: data) } private static func decodeEvent(_ dataText: String, as type: T.Type, eventName: String) throws -> T { guard let data = dataText.data(using: .utf8) else { let message = "Failed to decode SSE event '\(eventName)': payload is not UTF-8" SybilLog.error(SybilLog.network, message) throw APIError.decodingError(message: message) } do { return try Self.decodeJSON(type, from: data) } catch let decodingError as DecodingError { let details = SybilLog.describe(decodingError) let snippet = dataText.replacingOccurrences(of: "\n", with: " ").prefix(400) let message = "Failed to decode SSE event '\(eventName)': \(details). Payload: \(snippet)" SybilLog.error(SybilLog.network, message) throw APIError.decodingError(message: message) } } private static func decodeLastJSONLine(_ dataText: String, as type: T.Type) -> T? { let lines = dataText .split(whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } guard let last = lines.last, let data = last.data(using: .utf8) else { return nil } return try? Self.decodeJSON(type, from: data) } private static func flushSSEEvent( eventName: inout String, dataLines: inout [String] ) -> (name: String, payload: String)? { guard !dataLines.isEmpty else { eventName = "message" return nil } let name = eventName let payload = dataLines.joined(separator: "\n") dataLines.removeAll(keepingCapacity: true) eventName = "message" return (name, payload) } private static func trimLeadingWhitespace(_ text: Substring) -> String { var index = text.startIndex while index < text.endIndex, text[index].isWhitespace { index = text.index(after: index) } return String(text[index...]) } private static func encodeJSON(_ value: T) throws -> Data { let encoder = JSONEncoder() return try encoder.encode(value) } private static func wrapTransportError(_ error: Error, method: String, url: URL?) -> APIError { if let urlError = error as? URLError { return APIError.networkError( message: "Network error \(urlError.code.rawValue) while requesting \(method) \(url?.absoluteString ?? ""): \(urlError.localizedDescription)" ) } return APIError.networkError( message: "Network request failed for \(method) \(url?.absoluteString ?? ""): \(error.localizedDescription)" ) } private static func responseSnippet(_ data: Data) -> String { guard !data.isEmpty else { return "" } if let string = String(data: data, encoding: .utf8) { let normalized = string.replacingOccurrences(of: "\n", with: " ") return String(normalized.prefix(500)) } return "" } } struct CompletionStreamRequest: Codable, Sendable { var chatId: String? var provider: Provider var model: String var messages: [CompletionRequestMessage] } private struct ChatCreateBody: Encodable { var title: String? } private struct SearchCreateBody: Encodable { var title: String? var query: String? } private struct SearchChatCreateBody: Encodable { var title: String? }