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.4 KB · 205 lines swift
Raw Blame History
1//2//  Routes.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Maps inbound requests to endpoints: /health, /v1/models, /v1/models/{id};9//  /v1/chat/completions arrives in Phase 3. All errors are OpenAI-shaped.10//1112import Foundation13import NIOHTTP11415struct Routes: Sendable {16    let router: RequestRouter17    let auth: AuthMiddleware18    let cors: CORS19    let version: String20    let startedAt: Date21    let chat: ChatCompletionsRoute2223    private static let encoder: JSONEncoder = {24        let encoder = JSONEncoder()25        encoder.outputFormatting = [.withoutEscapingSlashes]26        return encoder27    }()2829    init(30        router: RequestRouter,31        auth: AuthMiddleware = AuthMiddleware(),32        cors: CORS = CORS(),33        version: String = "1.0.0",34        startedAt: Date = Date(),35        providerKey: @escaping @Sendable (ProviderID) -> String? = { provider in36            try? SecureKeyStore().key(for: provider)37        },38        usageMeter: UsageMeter = UsageMeter(),39        requestLog: RequestLogStore = RequestLogStore()40    ) {41        self.router = router42        self.auth = auth43        self.cors = cors44        self.version = version45        self.startedAt = startedAt46        self.chat = ChatCompletionsRoute(47            router: router,48            providerKey: providerKey,49            usageMeter: usageMeter,50            requestLog: requestLog,51            retryPolicy: RetryPolicy()52        )53    }5455    func handle(_ request: RouteRequest) async -> RouteResult {56        if request.method == .OPTIONS {57            return cors.preflightResponse()58        }5960        let path = request.path6162        // /health is deliberately unauthenticated (readiness probes).63        if request.method == .GET, path == "/health" {64            return withCORS(health())65        }6667        let localKey: APIKeyRecord?68        switch auth.authorize(request) {69        case .unauthorized(let message):70            return withCORS(OpenAIError.response(71                status: .unauthorized,72                message: message,73                type: "authentication_error",74                code: "invalid_api_key"75            ))76        case .allowed(let record):77            localKey = record78        }7980        switch (request.method, path) {81        case (.POST, "/v1/chat/completions"):82            return withCORS(await chat.handle(request, localKey: localKey))83        case (.GET, "/v1/models"):84            return withCORS(modelList())85        case (.GET, _) where path.hasPrefix("/v1/models/"):86            let id = String(path.dropFirst("/v1/models/".count))87                .removingPercentEncoding ?? String(path.dropFirst("/v1/models/".count))88            return withCORS(model(id: id))89        default:90            return withCORS(OpenAIError.response(91                status: .notFound,92                message: "Unknown request URL: \(request.method) \(path). The router serves /v1/chat/completions, /v1/models, and /health.",93                type: "invalid_request_error"94            ))95        }96    }9798    // MARK: - Endpoints99100    private func health() -> RouteResult {101        struct Health: Codable {102            var status: String103            var version: String104            var uptime: Int105            var models: Int106        }107        let payload = Health(108            status: "ok",109            version: version,110            uptime: Int(Date().timeIntervalSince(startedAt)),111            models: router.exposedModels.filter { !$0.disabled }.count112        )113        return json(payload)114    }115116    private func modelList() -> RouteResult {117        let created = Int(startedAt.timeIntervalSince1970)118        var entries: [OpenAIModelEntry] = []119        var aliasTargets: [String: String] = [:]120        for (alias, target) in router.aliases {121            aliasTargets[target] = alias122        }123        for (namespacedID, model, disabled) in router.exposedModels where !disabled {124            entries.append(OpenAIModelEntry(125                id: namespacedID,126                created: created,127                ownedBy: model.provider.rawValue,128                xZyquo: XZyquoModelInfo(129                    displayName: model.displayName,130                    contextWindow: model.contextWindow,131                    maxOutputTokens: model.maxOutputTokens,132                    vision: model.capabilities.vision,133                    tools: model.capabilities.tools,134                    reasoning: model.capabilities.reasoning,135                    inputPerMTok: model.pricing?.inputPerMTok,136                    outputPerMTok: model.pricing?.outputPerMTok,137                    alias: aliasTargets[namespacedID]138                )139            ))140        }141        return json(OpenAIModelList(data: entries))142    }143144    private func model(id: String) -> RouteResult {145        do {146            let resolution = try router.resolve(id)147            let entry = OpenAIModelEntry(148                id: resolution.namespacedID,149                created: Int(startedAt.timeIntervalSince1970),150                ownedBy: resolution.model.provider.rawValue,151                xZyquo: XZyquoModelInfo(152                    displayName: resolution.model.displayName,153                    contextWindow: resolution.model.contextWindow,154                    maxOutputTokens: resolution.model.maxOutputTokens,155                    vision: resolution.model.capabilities.vision,156                    tools: resolution.model.capabilities.tools,157                    reasoning: resolution.model.capabilities.reasoning,158                    inputPerMTok: resolution.model.pricing?.inputPerMTok,159                    outputPerMTok: resolution.model.pricing?.outputPerMTok,160                    alias: nil161                )162            )163            return json(entry)164        } catch let error as RequestRouter.RoutingError {165            return Self.routingErrorResponse(error)166        } catch {167            return OpenAIError.response(status: .internalServerError, message: "Internal error.", type: "server_error")168        }169    }170171    /// Shared mapping used by every route that resolves a model.172    static func routingErrorResponse(_ error: RequestRouter.RoutingError) -> RouteResult {173        switch error {174        case .unknownModel(let name), .disabledModel(let name):175            return OpenAIError.modelNotFound(name)176        case .ambiguousModel(let name, let candidates):177            return OpenAIError.response(178                status: .notFound,179                message: "The model `\(name)` is ambiguous — use a namespaced ID: \(candidates.joined(separator: ", ")).",180                type: "invalid_request_error",181                param: "model",182                code: "model_not_found"183            )184        }185    }186187    // MARK: - Helpers188189    private func json<T: Encodable>(_ payload: T, status: HTTPResponseStatus = .ok) -> RouteResult {190        guard let body = try? Self.encoder.encode(payload) else {191            return OpenAIError.response(status: .internalServerError, message: "Encoding failure.", type: "server_error")192        }193        return .complete(status: status, headers: [("Content-Type", "application/json")], body: body)194    }195196    private func withCORS(_ result: RouteResult) -> RouteResult {197        switch result {198        case .complete(let status, let headers, let body):199            return .complete(status: status, headers: headers + cors.headers, body: body)200        case .stream(let status, let headers, let body):201            return .stream(status: status, headers: headers + cors.headers, body: body)202        }203    }204}205