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%
16.7 KB · 383 lines swift
Raw Blame History
1//2//  GeminiTranslator.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Native Gemini generateContent / streamGenerateContent translation per9//  docs/ROUTER-RESEARCH.md §3.2 (decision D10): contents/parts with role10//  rename, systemInstruction, generationConfig, functionDeclarations, the11//  finishReason table (incl. the STOP+functionCall → tool_calls override),12//  and SSE chunks → OpenAI chunks (role synthesized, whole-argument tool13//  deltas, router-added [DONE]).14//1516import Foundation1718enum GeminiTranslator {19    // MARK: - Request (OpenAI → Gemini)2021    static func buildRequest(_ request: ChatCompletionRequest, model: AIModel) -> [String: Any] {22        var body: [String: Any] = [:]2324        let systemText = request.messages25            .filter { $0.role == "system" || $0.role == "developer" }26            .map(\.flattenedText)27            .filter { !$0.isEmpty }28            .joined(separator: "\n\n")29        if !systemText.isEmpty {30            body["systemInstruction"] = ["parts": [["text": systemText]]]31        }3233        // tool_call_id → function name, resolved from prior assistant turns34        // (functionResponse requires the name).35        var callNames: [String: String] = [:]36        for message in request.messages {37            for call in message.toolCalls ?? [] {38                if let id = call["id"] as? String,39                   let name = (call["function"] as? [String: Any])?["name"] as? String {40                    callNames[id] = name41                }42            }43        }4445        var contents: [[String: Any]] = []46        func append(role: String, parts: [[String: Any]]) {47            guard !parts.isEmpty else { return }48            if var last = contents.last, last["role"] as? String == role {49                var merged = last["parts"] as? [[String: Any]] ?? []50                merged.append(contentsOf: parts)51                last["parts"] = merged52                contents[contents.count - 1] = last53            } else {54                contents.append(["role": role, "parts": parts])55            }56        }5758        for message in request.messages {59            switch message.role {60            case "system", "developer":61                continue62            case "user":63                append(role: "user", parts: parts(for: message))64            case "assistant":65                var assistantParts = parts(for: message)66                for call in message.toolCalls ?? [] {67                    guard let function = call["function"] as? [String: Any],68                          let name = function["name"] as? String else { continue }69                    let arguments = function["arguments"] as? String ?? "{}"70                    let args = (try? JSONSerialization.jsonObject(with: Data(arguments.utf8))) as? [String: Any] ?? [:]71                    var functionCall: [String: Any] = ["name": name, "args": args]72                    if let id = call["id"] as? String { functionCall["id"] = id }73                    assistantParts.append(["functionCall": functionCall])74                }75                append(role: "model", parts: assistantParts)76            case "tool":77                let callID = message.toolCallID ?? ""78                let text = message.flattenedText79                // functionResponse.response must be a JSON object.80                let response = (try? JSONSerialization.jsonObject(with: Data(text.utf8))) as? [String: Any]81                    ?? ["result": text]82                var functionResponse: [String: Any] = [83                    "name": callNames[callID] ?? callID,84                    "response": response,85                ]86                if !callID.isEmpty { functionResponse["id"] = callID }87                append(role: "user", parts: [["functionResponse": functionResponse]])88            default:89                continue90            }91        }92        body["contents"] = contents9394        var generation: [String: Any] = [:]95        if let temperature = request.raw["temperature"] as? Double { generation["temperature"] = temperature }96        if let topP = request.raw["top_p"] as? Double { generation["topP"] = topP }97        if let topK = request.raw["top_k"] as? Int { generation["topK"] = topK }98        if let maxTokens = request.maxTokens { generation["maxOutputTokens"] = maxTokens }99        if let seed = request.raw["seed"] as? Int { generation["seed"] = seed }100        if let presence = request.raw["presence_penalty"] as? Double { generation["presencePenalty"] = presence }101        if let frequency = request.raw["frequency_penalty"] as? Double { generation["frequencyPenalty"] = frequency }102        if let stop = request.raw["stop"] as? String {103            generation["stopSequences"] = [stop]104        } else if let stop = request.raw["stop"] as? [String] {105            generation["stopSequences"] = stop106        }107        if let format = request.raw["response_format"] as? [String: Any] {108            switch format["type"] as? String {109            case "json_object":110                generation["responseMimeType"] = "application/json"111            case "json_schema":112                generation["responseMimeType"] = "application/json"113                if let schema = (format["json_schema"] as? [String: Any])?["schema"] {114                    generation["responseJsonSchema"] = schema115                }116            default:117                break118            }119        }120        if model.capabilities.reasoning, let effort = request.raw["reasoning_effort"] as? String {121            let budget: Int122            switch effort {123            case "minimal", "low": budget = 1024124            case "high", "xhigh", "max": budget = 24576125            default: budget = 8192126            }127            generation["thinkingConfig"] = ["thinkingBudget": budget, "includeThoughts": true]128        }129        if !generation.isEmpty { body["generationConfig"] = generation }130131        if let tools = request.tools {132            let declarations = tools.compactMap { tool -> [String: Any]? in133                guard let function = tool["function"] as? [String: Any],134                      let name = function["name"] as? String else { return nil }135                var declaration: [String: Any] = ["name": name]136                if let description = function["description"] as? String {137                    declaration["description"] = description138                }139                if var parameters = function["parameters"] as? [String: Any] {140                    parameters["$schema"] = nil141                    declaration["parameters"] = parameters142                }143                return declaration144            }145            if !declarations.isEmpty {146                body["tools"] = [["functionDeclarations": declarations]]147            }148        }149        var callingConfig: [String: Any]?150        switch request.raw["tool_choice"] {151        case let choice as String:152            switch choice {153            case "auto": callingConfig = ["mode": "AUTO"]154            case "required": callingConfig = ["mode": "ANY"]155            case "none": callingConfig = ["mode": "NONE"]156            default: break157            }158        case let choice as [String: Any]:159            if let function = choice["function"] as? [String: Any], let name = function["name"] as? String {160                callingConfig = ["mode": "ANY", "allowedFunctionNames": [name]]161            }162        default:163            break164        }165        if let callingConfig {166            body["toolConfig"] = ["functionCallingConfig": callingConfig]167        }168        return body169    }170171    private static func parts(for message: OAIMessage) -> [[String: Any]] {172        if let text = message.contentString {173            return text.isEmpty ? [] : [["text": text]]174        }175        guard let contentParts = message.contentParts else { return [] }176        return contentParts.compactMap { part in177            switch part["type"] as? String {178            case "text":179                let text = part["text"] as? String ?? ""180                return text.isEmpty ? nil : ["text": text]181            case "image_url":182                guard let image = part["image_url"] as? [String: Any],183                      let url = image["url"] as? String,184                      url.hasPrefix("data:"),185                      let comma = url.firstIndex(of: ",") else { return nil }186                let header = url[url.index(url.startIndex, offsetBy: 5)..<comma]187                let mimeType = header.split(separator: ";").first.map(String.init) ?? "image/png"188                return ["inlineData": [189                    "mimeType": mimeType,190                    "data": String(url[url.index(after: comma)...]),191                ]]192            default:193                return nil194            }195        }196    }197198    /// Remote (non data-URI) image URLs need inlining for Gemini; the route199    /// rejects them with a clear error instead of fetching arbitrary URLs.200    static func hasRemoteImageURL(_ request: ChatCompletionRequest) -> Bool {201        request.messages.contains { message in202            message.contentParts?.contains { part in203                guard (part["type"] as? String) == "image_url",204                      let url = (part["image_url"] as? [String: Any])?["url"] as? String else { return false }205                return !url.hasPrefix("data:")206            } ?? false207        }208    }209210    // MARK: - Shared mapping211212    static func finishReason(from raw: String?, hasFunctionCall: Bool) -> String {213        if hasFunctionCall { return "tool_calls" }214        switch raw {215        case "STOP", .none: return "stop"216        case "MAX_TOKENS": return "length"217        case "SAFETY", "PROHIBITED_CONTENT", "BLOCKLIST", "SPII", "IMAGE_SAFETY", "RECITATION":218            return "content_filter"219        default: return "stop"220        }221    }222223    static func normalizedUsage(_ metadata: [String: Any]) -> [String: Any] {224        let prompt = metadata["promptTokenCount"] as? Int ?? 0225        let candidates = metadata["candidatesTokenCount"] as? Int ?? 0226        let thoughts = metadata["thoughtsTokenCount"] as? Int ?? 0227        return UsageBuilder.build(228            promptTokens: prompt,229            completionTokens: candidates + thoughts, // OpenAI counts reasoning inside completion230            cachedTokens: metadata["cachedContentTokenCount"] as? Int,231            reasoningTokens: thoughts > 0 ? thoughts : nil232        )233    }234235    /// Empty-candidates + promptFeedback.blockReason → clear 400.236    static func blockReason(_ upstream: [String: Any]) -> String? {237        guard (upstream["candidates"] as? [[String: Any]] ?? []).isEmpty else { return nil }238        return (upstream["promptFeedback"] as? [String: Any])?["blockReason"] as? String239    }240241    // MARK: - Response (Gemini → OpenAI), non-streaming242243    static func translateResponse(_ upstream: [String: Any], emitter: ChunkEmitter) -> [String: Any] {244        let candidate = (upstream["candidates"] as? [[String: Any]])?.first ?? [:]245        let parts = (candidate["content"] as? [String: Any])?["parts"] as? [[String: Any]] ?? []246247        var text = ""248        var reasoning = ""249        var toolCalls: [[String: Any]] = []250        for part in parts {251            if let partText = part["text"] as? String {252                if part["thought"] as? Bool == true {253                    reasoning += partText254                } else {255                    text += partText256                }257            }258            if let functionCall = part["functionCall"] as? [String: Any] {259                let args = functionCall["args"] as? [String: Any] ?? [:]260                let arguments = String(261                    data: (try? JSONSerialization.data(withJSONObject: args)) ?? Data("{}".utf8),262                    encoding: .utf8263                ) ?? "{}"264                toolCalls.append([265                    "id": functionCall["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))",266                    "type": "function",267                    "function": ["name": functionCall["name"] as? String ?? "", "arguments": arguments],268                ])269            }270        }271272        var message: [String: Any] = ["role": "assistant"]273        message["content"] = toolCalls.isEmpty || !text.isEmpty ? text : NSNull()274        if !reasoning.isEmpty { message["reasoning_content"] = reasoning }275        if !toolCalls.isEmpty { message["tool_calls"] = toolCalls }276277        return emitter.completion(278            message: message,279            finishReason: finishReason(280                from: candidate["finishReason"] as? String,281                hasFunctionCall: !toolCalls.isEmpty282            ),283            usage: normalizedUsage(upstream["usageMetadata"] as? [String: Any] ?? [:])284        )285    }286287    // MARK: - Streaming (Gemini SSE → OpenAI chunks)288289    /// Each Gemini SSE data payload is a complete GenerateContentResponse290    /// carrying the increment; there is no upstream [DONE].291    struct StreamMachine {292        let emitter: ChunkEmitter293        let includeUsage: Bool294295        private var roleSent = false296        private var toolIndex = -1297        private var finishSent = false298        /// Accumulated assistant text for the request-log inspector (capped).299        private(set) var textPreview = ""300        private(set) var lastUsage: [String: Any]?301        private(set) var promptTokens = 0302        private(set) var completionTokens = 0303304        init(emitter: ChunkEmitter, includeUsage: Bool) {305            self.emitter = emitter306            self.includeUsage = includeUsage307        }308309        mutating func consume(_ event: SSEEvent) -> [Data] {310            guard let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any] else {311                return []312            }313            var payloads: [Data] = []314            if !roleSent {315                roleSent = true316                payloads.append(emitter.roleChunk())317            }318319            if let metadata = json["usageMetadata"] as? [String: Any] {320                lastUsage = metadata // cumulative; last one wins321                promptTokens = metadata["promptTokenCount"] as? Int ?? promptTokens322                completionTokens = (metadata["candidatesTokenCount"] as? Int ?? 0)323                    + (metadata["thoughtsTokenCount"] as? Int ?? 0)324            }325326            let candidate = (json["candidates"] as? [[String: Any]])?.first ?? [:]327            var sawFunctionCall = false328            for part in (candidate["content"] as? [String: Any])?["parts"] as? [[String: Any]] ?? [] {329                if let text = part["text"] as? String, !text.isEmpty {330                    if part["thought"] as? Bool == true {331                        payloads.append(emitter.reasoningChunk(text))332                    } else {333                        if textPreview.count < 20_000 { textPreview += text }334                        payloads.append(emitter.contentChunk(text))335                    }336                }337                if let functionCall = part["functionCall"] as? [String: Any] {338                    // Tool calls arrive complete: announce, then one full-339                    // arguments delta (strict SDKs accept single-shot args).340                    sawFunctionCall = true341                    toolIndex += 1342                    let args = functionCall["args"] as? [String: Any] ?? [:]343                    let arguments = String(344                        data: (try? JSONSerialization.data(withJSONObject: args)) ?? Data("{}".utf8),345                        encoding: .utf8346                    ) ?? "{}"347                    payloads.append(emitter.toolCallStartChunk(348                        toolIndex: toolIndex,349                        callID: functionCall["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))",350                        name: functionCall["name"] as? String ?? ""351                    ))352                    payloads.append(emitter.toolCallArgumentsChunk(toolIndex: toolIndex, fragment: arguments))353                }354            }355356            if let rawFinish = candidate["finishReason"] as? String, !finishSent {357                finishSent = true358                payloads.append(emitter.finishChunk(reason: GeminiTranslator.finishReason(359                    from: rawFinish,360                    hasFunctionCall: sawFunctionCall || toolIndex >= 0361                )))362            }363            return payloads364        }365366        /// Called when the upstream stream ends (no [DONE] from Gemini).367        mutating func finalPayloads() -> [Data] {368            var payloads: [Data] = []369            if !finishSent {370                finishSent = true371                payloads.append(emitter.finishChunk(reason: "stop"))372            }373            if includeUsage {374                payloads.append(emitter.usageChunk(375                    lastUsage.map(GeminiTranslator.normalizedUsage)376                        ?? UsageBuilder.build(promptTokens: promptTokens, completionTokens: completionTokens, estimated: true)377                ))378            }379            return payloads380        }381    }382}383