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%
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