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%
4.1 KB · 119 lines swift
Raw Blame History
1//2//  ChatCompletionRequest.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The parsed inbound OpenAI /v1/chat/completions request. The raw JSON object9//  is preserved (unknown keys pass through to OpenAI-compatible upstreams per10//  decision D5); typed accessors cover everything the router itself needs for11//  routing, translation, and capability checks.12//1314import Foundation1516struct ChatCompletionRequest {17    /// The request exactly as received (top-level JSON object).18    let raw: [String: Any]1920    let model: String21    let messages: [OAIMessage]22    let stream: Bool23    let includeUsage: Bool2425    enum ParseError: LocalizedError {26        case notAnObject27        case missing(String)28        case invalid(String, detail: String)2930        var errorDescription: String? {31            switch self {32            case .notAnObject: return "The request body must be a JSON object."33            case .missing(let param): return "Missing required parameter: '\(param)'."34            case .invalid(let param, let detail): return "Invalid value for '\(param)': \(detail)."35            }36        }3738        var param: String? {39            switch self {40            case .notAnObject: return nil41            case .missing(let param), .invalid(let param, _): return param42            }43        }44    }4546    init(body: Data) throws {47        guard let object = try? JSONSerialization.jsonObject(with: body),48              let dict = object as? [String: Any] else {49            throw ParseError.notAnObject50        }51        raw = dict5253        guard let model = dict["model"] as? String, !model.isEmpty else {54            throw ParseError.missing("model")55        }56        self.model = model5758        guard let rawMessages = dict["messages"] as? [[String: Any]], !rawMessages.isEmpty else {59            throw ParseError.missing("messages")60        }61        messages = try rawMessages.enumerated().map { index, message in62            try OAIMessage(json: message, index: index)63        }6465        stream = dict["stream"] as? Bool ?? false66        includeUsage = (dict["stream_options"] as? [String: Any])?["include_usage"] as? Bool ?? false6768        if let n = dict["n"] as? Int, n > 1 {69            // Router-wide policy (research §3.2.2): n>1 is rejected for70            // uniform behavior across upstreams.71            throw ParseError.invalid("n", detail: "the router supports n=1 only")72        }73    }7475    /// max_completion_tokens wins over the deprecated max_tokens.76    var maxTokens: Int? {77        (raw["max_completion_tokens"] as? Int) ?? (raw["max_tokens"] as? Int)78    }7980    var tools: [[String: Any]]? { raw["tools"] as? [[String: Any]] }81    var hasTools: Bool { !(tools ?? []).isEmpty }8283    /// Any message carrying an image content part (vision capability check).84    var hasImageContent: Bool {85        messages.contains { message in86            message.contentParts?.contains { ($0["type"] as? String) == "image_url" } ?? false87        }88    }89}9091/// One inbound message, loosely typed: content may be a string, an array of92/// content parts, or null (assistant tool-call turns).93struct OAIMessage {94    let role: String95    let json: [String: Any]9697    init(json: [String: Any], index: Int) throws {98        guard let role = json["role"] as? String else {99            throw ChatCompletionRequest.ParseError.invalid("messages[\(index)].role", detail: "missing role")100        }101        self.role = role102        self.json = json103    }104105    var contentString: String? { json["content"] as? String }106    var contentParts: [[String: Any]]? { json["content"] as? [[String: Any]] }107    var toolCalls: [[String: Any]]? { json["tool_calls"] as? [[String: Any]] }108    var toolCallID: String? { json["tool_call_id"] as? String }109110    /// All text in this message (string content or text parts joined).111    var flattenedText: String {112        if let text = contentString { return text }113        guard let parts = contentParts else { return "" }114        return parts.compactMap { part in115            (part["type"] as? String) == "text" ? part["text"] as? String : nil116        }.joined(separator: "\n")117    }118}119