555 lines
22 KiB
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?
|
|
}
|