// // ChatCompletionRequest.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The parsed inbound OpenAI /v1/chat/completions request. The raw JSON object // is preserved (unknown keys pass through to OpenAI-compatible upstreams per // decision D5); typed accessors cover everything the router itself needs for // routing, translation, and capability checks. // import Foundation struct ChatCompletionRequest { /// The request exactly as received (top-level JSON object). let raw: [String: Any] let model: String let messages: [OAIMessage] let stream: Bool let includeUsage: Bool enum ParseError: LocalizedError { case notAnObject case missing(String) case invalid(String, detail: String) var errorDescription: String? { switch self { case .notAnObject: return "The request body must be a JSON object." case .missing(let param): return "Missing required parameter: '\(param)'." case .invalid(let param, let detail): return "Invalid value for '\(param)': \(detail)." } } var param: String? { switch self { case .notAnObject: return nil case .missing(let param), .invalid(let param, _): return param } } } init(body: Data) throws { guard let object = try? JSONSerialization.jsonObject(with: body), let dict = object as? [String: Any] else { throw ParseError.notAnObject } raw = dict guard let model = dict["model"] as? String, !model.isEmpty else { throw ParseError.missing("model") } self.model = model guard let rawMessages = dict["messages"] as? [[String: Any]], !rawMessages.isEmpty else { throw ParseError.missing("messages") } messages = try rawMessages.enumerated().map { index, message in try OAIMessage(json: message, index: index) } stream = dict["stream"] as? Bool ?? false includeUsage = (dict["stream_options"] as? [String: Any])?["include_usage"] as? Bool ?? false if let n = dict["n"] as? Int, n > 1 { // Router-wide policy (research ยง3.2.2): n>1 is rejected for // uniform behavior across upstreams. throw ParseError.invalid("n", detail: "the router supports n=1 only") } } /// max_completion_tokens wins over the deprecated max_tokens. var maxTokens: Int? { (raw["max_completion_tokens"] as? Int) ?? (raw["max_tokens"] as? Int) } var tools: [[String: Any]]? { raw["tools"] as? [[String: Any]] } var hasTools: Bool { !(tools ?? []).isEmpty } /// Any message carrying an image content part (vision capability check). var hasImageContent: Bool { messages.contains { message in message.contentParts?.contains { ($0["type"] as? String) == "image_url" } ?? false } } } /// One inbound message, loosely typed: content may be a string, an array of /// content parts, or null (assistant tool-call turns). struct OAIMessage { let role: String let json: [String: Any] init(json: [String: Any], index: Int) throws { guard let role = json["role"] as? String else { throw ChatCompletionRequest.ParseError.invalid("messages[\(index)].role", detail: "missing role") } self.role = role self.json = json } var contentString: String? { json["content"] as? String } var contentParts: [[String: Any]]? { json["content"] as? [[String: Any]] } var toolCalls: [[String: Any]]? { json["tool_calls"] as? [[String: Any]] } var toolCallID: String? { json["tool_call_id"] as? String } /// All text in this message (string content or text parts joined). var flattenedText: String { if let text = contentString { return text } guard let parts = contentParts else { return "" } return parts.compactMap { part in (part["type"] as? String) == "text" ? part["text"] as? String : nil }.joined(separator: "\n") } }