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.6 KB · 253 lines swift
Raw Blame History
1//2//  OpenAINormalizer.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Canonical OpenAI wire format served by the router. Phase 2 carries the9//  error body and the /v1/models list shapes; Phase 3 adds the full10//  chat/completions request/response/chunk types and normalization.11//1213import Foundation14import NIOHTTP11516// MARK: - Error body (spec: {"error": {"message", "type", "param", "code"}})1718struct OpenAIError: Codable {19    struct Body: Codable {20        var message: String21        var type: String22        var param: String?23        var code: String?24    }2526    var error: Body2728    private static let encoder: JSONEncoder = {29        let encoder = JSONEncoder()30        encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys]31        return encoder32    }()3334    /// A complete error response in OpenAI wire shape.35    static func response(36        status: HTTPResponseStatus,37        message: String,38        type: String,39        param: String? = nil,40        code: String? = nil,41        extraHeaders: [(String, String)] = []42    ) -> RouteResult {43        let payload = OpenAIError(error: Body(message: message, type: type, param: param, code: code))44        let body = (try? encoder.encode(payload)) ?? Data(#"{"error":{"message":"internal error"}}"#.utf8)45        return .complete(46            status: status,47            headers: [("Content-Type", "application/json")] + extraHeaders,48            body: body49        )50    }5152    /// The exact 404 OpenAI returns for unknown models.53    static func modelNotFound(_ model: String) -> RouteResult {54        response(55            status: .notFound,56            message: "The model `\(model)` does not exist or you do not have access to it.",57            type: "invalid_request_error",58            param: "model",59            code: "model_not_found"60        )61    }62}6364// MARK: - /v1/models list shapes6566struct OpenAIModelEntry: Codable {67    var id: String68    var object = "model"69    var created: Int70    var ownedBy: String71    /// Router extension: catalog metadata (context, pricing, capabilities).72    var xZyquo: XZyquoModelInfo?7374    enum CodingKeys: String, CodingKey {75        case id, object, created76        case ownedBy = "owned_by"77        case xZyquo = "x_zyquo"78    }79}8081struct XZyquoModelInfo: Codable {82    var displayName: String83    var contextWindow: Int84    var maxOutputTokens: Int?85    var vision: Bool86    var tools: Bool87    var reasoning: Bool88    var inputPerMTok: Double?89    var outputPerMTok: Double?90    var alias: String?9192    enum CodingKeys: String, CodingKey {93        case displayName = "display_name"94        case contextWindow = "context_window"95        case maxOutputTokens = "max_output_tokens"96        case vision, tools, reasoning, alias97        case inputPerMTok = "input_per_mtok"98        case outputPerMTok = "output_per_mtok"99    }100}101102struct OpenAIModelList: Codable {103    var object = "list"104    var data: [OpenAIModelEntry]105}106107// MARK: - Response / chunk construction108109/// Builds spec-exact `chat.completion` and `chat.completion.chunk` JSON.110/// One emitter per request: `id` and `created` stay constant for a stream.111struct ChunkEmitter {112    let id: String113    let created: Int114    /// Namespaced router model id, echoed on every chunk/response.115    let model: String116117    init(model: String) {118        var hex = ""119        for _ in 0..<12 { hex += String(format: "%x", Int.random(in: 0...15)) }120        id = "chatcmpl-\(hex)"121        created = Int(Date().timeIntervalSince1970)122        self.model = model123    }124125    static func serialize(_ object: [String: Any]) -> Data {126        (try? JSONSerialization.data(withJSONObject: object)) ?? Data("{}".utf8)127    }128129    private func envelope(delta: [String: Any]?, finishReason: String?) -> [String: Any] {130        [131            "id": id,132            "object": "chat.completion.chunk",133            "created": created,134            "model": model,135            "choices": [[136                "index": 0,137                "delta": delta ?? [:],138                "finish_reason": finishReason as Any,139            ] as [String: Any]],140        ]141    }142143    /// First chunk of every stream: the role delta.144    func roleChunk() -> Data {145        Self.serialize(envelope(delta: ["role": "assistant", "content": ""], finishReason: nil))146    }147148    func contentChunk(_ text: String) -> Data {149        Self.serialize(envelope(delta: ["content": text], finishReason: nil))150    }151152    func reasoningChunk(_ text: String) -> Data {153        Self.serialize(envelope(delta: ["reasoning_content": text], finishReason: nil))154    }155156    /// Announces a tool call: id + name once, empty arguments accumulator.157    func toolCallStartChunk(toolIndex: Int, callID: String, name: String) -> Data {158        Self.serialize(envelope(159            delta: ["tool_calls": [[160                "index": toolIndex,161                "id": callID,162                "type": "function",163                "function": ["name": name, "arguments": ""],164            ] as [String: Any]]],165            finishReason: nil166        ))167    }168169    func toolCallArgumentsChunk(toolIndex: Int, fragment: String) -> Data {170        Self.serialize(envelope(171            delta: ["tool_calls": [[172                "index": toolIndex,173                "function": ["arguments": fragment],174            ] as [String: Any]]],175            finishReason: nil176        ))177    }178179    func finishChunk(reason: String) -> Data {180        Self.serialize(envelope(delta: [:], finishReason: reason))181    }182183    /// Usage chunk: empty `choices` array, only when the client asked for it.184    func usageChunk(_ usage: [String: Any]) -> Data {185        Self.serialize([186            "id": id,187            "object": "chat.completion.chunk",188            "created": created,189            "model": model,190            "choices": [] as [Any],191            "usage": usage,192        ])193    }194195    /// Complete non-streaming `chat.completion` object.196    func completion(197        message: [String: Any],198        finishReason: String,199        usage: [String: Any],200        extras: [String: Any] = [:]201    ) -> [String: Any] {202        var object: [String: Any] = [203            "id": id,204            "object": "chat.completion",205            "created": created,206            "model": model,207            "choices": [[208                "index": 0,209                "message": message,210                "finish_reason": finishReason,211            ] as [String: Any]],212            "usage": usage,213        ]214        for (key, value) in extras {215            object[key] = value216        }217        return object218    }219}220221/// OpenAI-shape usage dictionaries from normalized numbers.222enum UsageBuilder {223    static func build(224        promptTokens: Int,225        completionTokens: Int,226        cachedTokens: Int? = nil,227        reasoningTokens: Int? = nil,228        estimated: Bool = false229    ) -> [String: Any] {230        var usage: [String: Any] = [231            "prompt_tokens": promptTokens,232            "completion_tokens": completionTokens,233            "total_tokens": promptTokens + completionTokens,234        ]235        if let cachedTokens, cachedTokens > 0 {236            usage["prompt_tokens_details"] = ["cached_tokens": cachedTokens]237        }238        if let reasoningTokens, reasoningTokens > 0 {239            usage["completion_tokens_details"] = ["reasoning_tokens": reasoningTokens]240        }241        if estimated {242            usage["x_zyquo"] = ["usage_estimated": true]243        }244        return usage245    }246247    /// Rough local estimation (~4 chars/token) used only when the upstream248    /// reports nothing; always flagged via x_zyquo.usage_estimated.249    static func estimateTokens(_ text: String) -> Int {250        max(1, text.count / 4)251    }252}253