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