SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%
7.8 KB · 193 lines swift
Raw Blame History
1//2//  HTTPServer.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Embedded HTTP/1.1 server on SwiftNIO's structured-concurrency APIs9//  (NIOAsyncChannel). One task per connection, one request at a time per10//  connection (keep-alive), spec-exact SSE via SSEWriter. Cancelling the11//  server task stops the accept loop and cancels in-flight connections,12//  which propagates into upstream provider calls.13//1415import Foundation16import NIOCore17import NIOFoundationCompat18import NIOHTTP119import NIOPosix2021/// What the server does with one parsed request.22/// `.complete` answers with a full buffered body; `.stream` hands an SSEWriter23/// to the route (Phase 3 streaming completions).24enum RouteResult {25    case complete(status: HTTPResponseStatus, headers: [(String, String)], body: Data)26    case stream(status: HTTPResponseStatus, headers: [(String, String)], body: (SSEWriter) async throws -> Void)27}2829/// One parsed inbound HTTP request, as handed to the route layer.30struct RouteRequest {31    let method: HTTPMethod32    let uri: String33    let headers: HTTPHeaders34    let body: Data3536    /// URI path without the query string.37    var path: String {38        uri.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)39            .first.map(String.init) ?? uri40    }41}4243enum ServerError: LocalizedError {44    case portInUse(host: String, port: Int)45    case bindFailed(String)4647    var errorDescription: String? {48        switch self {49        case .portInUse(_, let port):50            return "Port \(port) is already in use — try \(port + 1), or stop the other process."51        case .bindFailed(let detail):52            return "Could not start the server: \(detail)"53        }54    }55}5657/// The embedded server. Create one per Start; it is single-use.58final class HTTPServer: Sendable {59    /// Maximum buffered request body (base64 images make chat bodies large).60    static let maxBodyBytes = 32 * 1024 * 10246162    let host: String63    let port: Int64    private let handler: @Sendable (RouteRequest) async -> RouteResult6566    init(host: String, port: Int, handler: @escaping @Sendable (RouteRequest) async -> RouteResult) {67        self.host = host68        self.port = port69        self.handler = handler70    }7172    /// Binds and serves until the surrounding task is cancelled.73    /// `onRunning` fires once the socket is bound and accepting.74    func run(onRunning: @escaping @Sendable () -> Void) async throws {75        let serverChannel: NIOAsyncChannel<NIOAsyncChannel<HTTPServerRequestPart, HTTPServerResponsePart>, Never>76        do {77            serverChannel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)78                .serverChannelOption(ChannelOptions.backlog, value: 64)79                .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)80                .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)81                .bind(host: host, port: port) { channel in82                    channel.eventLoop.makeCompletedFuture {83                        try channel.pipeline.syncOperations.configureHTTPServerPipeline(withErrorHandling: true)84                        return try NIOAsyncChannel(wrappingChannelSynchronously: channel)85                    }86                }87        } catch let error as IOError where error.errnoCode == EADDRINUSE {88            throw ServerError.portInUse(host: host, port: port)89        } catch let error as NIOCore.ChannelError {90            throw ServerError.bindFailed(String(describing: error))91        }9293        onRunning()9495        // Cancellation of the surrounding task ends the accept iteration; the96        // task group then cancels and awaits every in-flight connection, so97        // shutdown is clean end-to-end. (withThrowingTaskGroup rather than a98        // discarding group: the package targets macOS 13.)99        try await serverChannel.executeThenClose { inbound, _ in100            try await withThrowingTaskGroup(of: Void.self) { group in101                for try await connection in inbound {102                    group.addTask { [handler] in103                        await Self.serve(connection: connection, handler: handler)104                    }105                }106            }107        }108    }109110    // MARK: - Per-connection loop111112    private static func serve(113        connection: NIOAsyncChannel<HTTPServerRequestPart, HTTPServerResponsePart>,114        handler: @Sendable (RouteRequest) async -> RouteResult115    ) async {116        try? await connection.executeThenClose { inbound, outbound in117            var iterator = inbound.makeAsyncIterator()118            while let part = try await iterator.next() {119                guard case .head(let head) = part else { continue }120121                var bodyBuffer = ByteBuffer()122                var tooLarge = false123                readLoop: while let next = try await iterator.next() {124                    switch next {125                    case .body(var chunk):126                        if bodyBuffer.readableBytes + chunk.readableBytes > maxBodyBytes {127                            tooLarge = true128                        } else {129                            bodyBuffer.writeBuffer(&chunk)130                        }131                    case .end:132                        break readLoop133                    case .head:134                        return  // protocol violation; drop the connection135                    }136                }137138                let keepAlive = head.isKeepAlive139                if tooLarge {140                    try await Self.write(141                        result: OpenAIError.response(142                            status: .payloadTooLarge,143                            message: "Request body exceeds the \(maxBodyBytes / (1024 * 1024)) MB limit.",144                            type: "invalid_request_error"145                        ),146                        version: head.version, keepAlive: false, outbound: outbound147                    )148                    return149                }150151                let request = RouteRequest(152                    method: head.method,153                    uri: head.uri,154                    headers: head.headers,155                    body: bodyBuffer.readData(length: bodyBuffer.readableBytes) ?? Data()156                )157                let result = await handler(request)158                try await Self.write(result: result, version: head.version, keepAlive: keepAlive, outbound: outbound)159                if !keepAlive { return }160            }161        }162    }163164    private static func write(165        result: RouteResult,166        version: HTTPVersion,167        keepAlive: Bool,168        outbound: NIOAsyncChannelOutboundWriter<HTTPServerResponsePart>169    ) async throws {170        switch result {171        case .complete(let status, let extraHeaders, let body):172            var headers = HTTPHeaders(extraHeaders)173            headers.replaceOrAdd(name: "Content-Length", value: String(body.count))174            headers.replaceOrAdd(name: "Connection", value: keepAlive ? "keep-alive" : "close")175            try await outbound.write(.head(HTTPResponseHead(version: version, status: status, headers: headers)))176            if !body.isEmpty {177                try await outbound.write(.body(.byteBuffer(ByteBuffer(bytes: body))))178            }179            try await outbound.write(.end(nil))180181        case .stream(let status, let extraHeaders, let body):182            var headers = HTTPHeaders(extraHeaders)183            headers.replaceOrAdd(name: "Content-Type", value: "text/event-stream")184            headers.replaceOrAdd(name: "Cache-Control", value: "no-cache")185            headers.replaceOrAdd(name: "Connection", value: "close")186            try await outbound.write(.head(HTTPResponseHead(version: version, status: status, headers: headers)))187            let writer = SSEWriter(outbound: outbound)188            try await body(writer)189            try await outbound.write(.end(nil))190        }191    }192}193