Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift

555 lines
22 KiB
Swift

import Foundation
struct APIConfiguration: Sendable {
var baseURL: URL
var authToken: String?
}
struct AnyEncodable: Encodable {
private let encodeClosure: (Encoder) throws -> Void
init<T: Encodable>(_ 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
private static let iso8601FormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
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 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 ?? "<unknown>")"
)
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 ?? "<unknown>")"
)
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<Response: Decodable>(
_ 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 ?? "<unknown>")"
)
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 ?? "<unknown>")"
)
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 ?? "<unknown>")"
)
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<T: Decodable>(_ 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<T: Decodable>(_ 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<T: Decodable>(_ 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<T: Encodable>(_ 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 ?? "<unknown>"): \(urlError.localizedDescription)"
)
}
return APIError.networkError(
message: "Network request failed for \(method) \(url?.absoluteString ?? "<unknown>"): \(error.localizedDescription)"
)
}
private static func responseSnippet(_ data: Data) -> String {
guard !data.isEmpty else { return "<empty>" }
if let string = String(data: data, encoding: .utf8) {
let normalized = string.replacingOccurrences(of: "\n", with: " ")
return String(normalized.prefix(500))
}
return "<non-utf8 body, \(data.count) bytes>"
}
}
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?
}