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// AnthropicTranslator.swift3// Zyquo Router4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Bidirectional translation between OpenAI chat/completions and the9// Anthropic Messages API, per docs/ROUTER-RESEARCH.md §3.1: system10// extraction, turn merging, tools/tool_choice, required max_tokens,11// stop_reason/usage mapping, and the SSE event state machine that emits12// byte-exact OpenAI chunks.13//1415import Foundation1617enum AnthropicTranslator {18 // MARK: - Request (OpenAI → Anthropic)1920 static func buildRequest(_ request: ChatCompletionRequest, model: AIModel) -> [String: Any] {21 var body: [String: Any] = [22 "model": model.id,23 // max_tokens is REQUIRED: synthesize from the catalog when omitted.24 "max_tokens": request.maxTokens ?? model.maxOutputTokens ?? 4096,25 ]2627 // System/developer messages → top-level system.28 let systemText = request.messages29 .filter { $0.role == "system" || $0.role == "developer" }30 .map(\.flattenedText)31 .filter { !$0.isEmpty }32 .joined(separator: "\n\n")33 var system = systemText3435 // Conversation messages with Anthropic's constraints: only36 // user/assistant roles, alternating, first must be user; tool results37 // become user tool_result blocks; consecutive same-role turns merge.38 var messages: [[String: Any]] = []39 func append(role: String, blocks: [[String: Any]]) {40 guard !blocks.isEmpty else { return }41 if var last = messages.last, last["role"] as? String == role {42 var content = last["content"] as? [[String: Any]] ?? []43 content.append(contentsOf: blocks)44 last["content"] = content45 messages[messages.count - 1] = last46 } else {47 messages.append(["role": role, "content": blocks])48 }49 }5051 for message in request.messages {52 switch message.role {53 case "system", "developer":54 continue55 case "user":56 append(role: "user", blocks: contentBlocks(message))57 case "assistant":58 var blocks = contentBlocks(message)59 for call in message.toolCalls ?? [] {60 guard let function = call["function"] as? [String: Any] else { continue }61 let arguments = function["arguments"] as? String ?? "{}"62 let input = (try? JSONSerialization.jsonObject(with: Data(arguments.utf8))) as? [String: Any] ?? [:]63 blocks.append([64 "type": "tool_use",65 "id": call["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))",66 "name": function["name"] as? String ?? "unknown",67 "input": input,68 ])69 }70 append(role: "assistant", blocks: blocks)71 case "tool":72 append(role: "user", blocks: [[73 "type": "tool_result",74 "tool_use_id": message.toolCallID ?? "",75 "content": message.flattenedText,76 ]])77 default:78 continue79 }80 }81 if messages.first?["role"] as? String != "user" {82 messages.insert(["role": "user", "content": [["type": "text", "text": "(continue)"]]], at: 0)83 }84 body["messages"] = messages8586 // Sampling params (temperature clamped to Anthropic's 0–1).87 if let temperature = request.raw["temperature"] as? Double {88 body["temperature"] = min(temperature, 1.0)89 }90 if let topP = request.raw["top_p"] as? Double { body["top_p"] = topP }91 if let topK = request.raw["top_k"] as? Int { body["top_k"] = topK }92 if let stop = request.raw["stop"] as? String {93 body["stop_sequences"] = [stop]94 } else if let stop = request.raw["stop"] as? [String] {95 body["stop_sequences"] = stop96 }97 if let user = request.raw["user"] as? String {98 body["metadata"] = ["user_id": user]99 }100101 // Tools + tool_choice.102 if let tools = request.tools {103 body["tools"] = tools.compactMap { tool -> [String: Any]? in104 guard let function = tool["function"] as? [String: Any],105 let name = function["name"] as? String else { return nil }106 var entry: [String: Any] = [107 "name": name,108 "input_schema": function["parameters"] as? [String: Any]109 ?? ["type": "object", "properties": [String: Any]()],110 ]111 if let description = function["description"] as? String {112 entry["description"] = description113 }114 return entry115 }116 }117 var toolChoice: [String: Any]?118 switch request.raw["tool_choice"] {119 case let choice as String:120 switch choice {121 case "auto": toolChoice = ["type": "auto"]122 case "required": toolChoice = ["type": "any"]123 case "none": toolChoice = ["type": "none"]124 default: break125 }126 case let choice as [String: Any]:127 if let function = choice["function"] as? [String: Any], let name = function["name"] as? String {128 toolChoice = ["type": "tool", "name": name]129 }130 default:131 break132 }133 if request.raw["parallel_tool_calls"] as? Bool == false, request.hasTools {134 var choice = toolChoice ?? ["type": "auto"]135 choice["disable_parallel_tool_use"] = true136 toolChoice = choice137 }138 if let toolChoice { body["tool_choice"] = toolChoice }139140 // JSON mode (best-effort system steering; json_schema via the current141 // structured-output surface is provider-verified in Phase 7).142 if let format = request.raw["response_format"] as? [String: Any],143 let type = format["type"] as? String, type == "json_object" || type == "json_schema" {144 let instruction = "You must respond with valid JSON only — no prose, no markdown fences."145 system = system.isEmpty ? instruction : system + "\n\n" + instruction146 }147 if !system.isEmpty { body["system"] = system }148149 // Reasoning: standard reasoning_effort → thinking config; raw150 // `thinking` extra-body always wins. The Claude 4.7+/5 family rejects151 // {"type":"enabled"} and wants adaptive thinking (verified Phase 7).152 if let thinking = request.raw["thinking"] as? [String: Any] {153 body["thinking"] = thinking154 } else if model.capabilities.reasoning,155 let effort = request.raw["reasoning_effort"] as? String {156 if usesAdaptiveThinking(model.id) {157 body["thinking"] = ["type": "adaptive"]158 } else {159 let budget: Int160 switch effort {161 case "minimal", "low": budget = 1024162 case "high", "xhigh", "max": budget = 24576163 default: budget = 8192164 }165 if let maxTokens = body["max_tokens"] as? Int, maxTokens <= budget {166 body["max_tokens"] = budget + 4096167 }168 body["thinking"] = ["type": "enabled", "budget_tokens": budget]169 }170 }171172 body["stream"] = request.stream173 return body174 }175176 /// Claude 4.7+, 4.8 and the 5 family take adaptive thinking only;177 /// budget-based "enabled" thinking 400s (Phase 7 verified).178 static func usesAdaptiveThinking(_ modelID: String) -> Bool {179 if modelID.range(of: #"claude-(opus|sonnet|haiku|fable)-5"#, options: .regularExpression) != nil {180 return true181 }182 return modelID.range(of: #"claude-(opus|sonnet|haiku)-4-(7|8|9)"#, options: .regularExpression) != nil183 }184185 private static func contentBlocks(_ message: OAIMessage) -> [[String: Any]] {186 if let text = message.contentString {187 return text.isEmpty ? [] : [["type": "text", "text": text]]188 }189 guard let parts = message.contentParts else { return [] }190 return parts.compactMap { part in191 switch part["type"] as? String {192 case "text":193 let text = part["text"] as? String ?? ""194 return text.isEmpty ? nil : ["type": "text", "text": text]195 case "image_url":196 guard let image = part["image_url"] as? [String: Any],197 let url = image["url"] as? String else { return nil }198 if url.hasPrefix("data:"),199 let comma = url.firstIndex(of: ",") {200 let header = url[url.index(url.startIndex, offsetBy: 5)..<comma]201 let mediaType = header.split(separator: ";").first.map(String.init) ?? "image/png"202 return ["type": "image", "source": [203 "type": "base64",204 "media_type": mediaType,205 "data": String(url[url.index(after: comma)...]),206 ]]207 }208 return ["type": "image", "source": ["type": "url", "url": url]]209 default:210 return nil211 }212 }213 }214215 // MARK: - Shared mapping216217 static func finishReason(from stopReason: String?) -> String {218 switch stopReason {219 case "end_turn", "stop_sequence", "pause_turn", .none: return "stop"220 case "max_tokens", "model_context_window_exceeded": return "length"221 case "tool_use": return "tool_calls"222 case "refusal": return "content_filter"223 default: return "stop"224 }225 }226227 /// prompt_tokens include cache reads/writes (Anthropic excludes them).228 static func normalizedUsage(_ usage: [String: Any]) -> [String: Any] {229 let input = usage["input_tokens"] as? Int ?? 0230 let cacheRead = usage["cache_read_input_tokens"] as? Int ?? 0231 let cacheCreation = usage["cache_creation_input_tokens"] as? Int ?? 0232 let output = usage["output_tokens"] as? Int ?? 0233 return UsageBuilder.build(234 promptTokens: input + cacheRead + cacheCreation,235 completionTokens: output,236 cachedTokens: cacheRead > 0 ? cacheRead : nil237 )238 }239240 // MARK: - Response (Anthropic → OpenAI), non-streaming241242 static func translateResponse(_ upstream: [String: Any], emitter: ChunkEmitter) -> [String: Any] {243 var text = ""244 var reasoning = ""245 var toolCalls: [[String: Any]] = []246 for block in upstream["content"] as? [[String: Any]] ?? [] {247 switch block["type"] as? String {248 case "text":249 text += block["text"] as? String ?? ""250 case "thinking":251 reasoning += block["thinking"] as? String ?? ""252 case "tool_use":253 let input = block["input"] as? [String: Any] ?? [:]254 let arguments = String(255 data: (try? JSONSerialization.data(withJSONObject: input)) ?? Data("{}".utf8),256 encoding: .utf8257 ) ?? "{}"258 toolCalls.append([259 "id": block["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))",260 "type": "function",261 "function": ["name": block["name"] as? String ?? "", "arguments": arguments],262 ])263 default:264 break265 }266 }267268 var message: [String: Any] = ["role": "assistant"]269 message["content"] = toolCalls.isEmpty || !text.isEmpty ? text : NSNull()270 if !reasoning.isEmpty { message["reasoning_content"] = reasoning }271 if !toolCalls.isEmpty { message["tool_calls"] = toolCalls }272273 return emitter.completion(274 message: message,275 finishReason: finishReason(from: upstream["stop_reason"] as? String),276 usage: normalizedUsage(upstream["usage"] as? [String: Any] ?? [:])277 )278 }279280 // MARK: - Streaming state machine (Anthropic events → OpenAI chunks)281282 /// Feed each upstream SSE event in; write the produced OpenAI chunks out.283 struct StreamMachine {284 let emitter: ChunkEmitter285 let includeUsage: Bool286287 private var toolIndex = -1288 private var currentBlockIsTool = false289 /// Accumulated assistant text for the request-log inspector (capped).290 private(set) var textPreview = ""291 private(set) var promptTokens = 0292 private(set) var cachedTokens = 0293 private(set) var completionTokens = 0294 private(set) var finishReasonSent: String?295 private(set) var upstreamError: (status: Int, message: String)?296297 init(emitter: ChunkEmitter, includeUsage: Bool) {298 self.emitter = emitter299 self.includeUsage = includeUsage300 }301302 /// Returns the OpenAI SSE `data:` payloads to emit for one event,303 /// and whether the stream is finished.304 mutating func consume(_ event: SSEEvent) -> (payloads: [Data], done: Bool) {305 guard let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any] else {306 return ([], false)307 }308 switch event.event ?? json["type"] as? String ?? "" {309 case "message_start":310 if let usage = (json["message"] as? [String: Any])?["usage"] as? [String: Any] {311 promptTokens = (usage["input_tokens"] as? Int ?? 0)312 + (usage["cache_read_input_tokens"] as? Int ?? 0)313 + (usage["cache_creation_input_tokens"] as? Int ?? 0)314 cachedTokens = usage["cache_read_input_tokens"] as? Int ?? 0315 }316 return ([emitter.roleChunk()], false)317318 case "content_block_start":319 guard let block = json["content_block"] as? [String: Any] else { return ([], false) }320 if block["type"] as? String == "tool_use" {321 toolIndex += 1322 currentBlockIsTool = true323 return ([emitter.toolCallStartChunk(324 toolIndex: toolIndex,325 callID: block["id"] as? String ?? "call_\(UUID().uuidString.prefix(12))",326 name: block["name"] as? String ?? ""327 )], false)328 }329 currentBlockIsTool = false330 return ([], false)331332 case "content_block_delta":333 guard let delta = json["delta"] as? [String: Any] else { return ([], false) }334 switch delta["type"] as? String {335 case "text_delta":336 let text = delta["text"] as? String ?? ""337 if textPreview.count < 20_000 { textPreview += text }338 return (text.isEmpty ? [] : [emitter.contentChunk(text)], false)339 case "input_json_delta":340 let fragment = delta["partial_json"] as? String ?? ""341 guard !fragment.isEmpty, currentBlockIsTool else { return ([], false) }342 return ([emitter.toolCallArgumentsChunk(toolIndex: toolIndex, fragment: fragment)], false)343 case "thinking_delta":344 let thinking = delta["thinking"] as? String ?? ""345 return (thinking.isEmpty ? [] : [emitter.reasoningChunk(thinking)], false)346 default: // signature_delta and friends: log-only material347 return ([], false)348 }349350 case "content_block_stop":351 currentBlockIsTool = false352 return ([], false)353354 case "message_delta":355 if let usage = json["usage"] as? [String: Any],356 let output = usage["output_tokens"] as? Int {357 completionTokens = output // cumulative358 }359 if let delta = json["delta"] as? [String: Any],360 let stopReason = delta["stop_reason"] as? String {361 let reason = AnthropicTranslator.finishReason(from: stopReason)362 finishReasonSent = reason363 return ([emitter.finishChunk(reason: reason)], false)364 }365 return ([], false)366367 case "message_stop":368 var payloads: [Data] = []369 if includeUsage {370 payloads.append(emitter.usageChunk(UsageBuilder.build(371 promptTokens: promptTokens,372 completionTokens: completionTokens,373 cachedTokens: cachedTokens > 0 ? cachedTokens : nil374 )))375 }376 return (payloads, true)377378 case "error":379 let detail = (json["error"] as? [String: Any])?["message"] as? String ?? "upstream stream error"380 upstreamError = (502, detail)381 let frame = OpenAIError(error: .init(382 message: detail, type: "api_error", param: nil,383 code: (json["error"] as? [String: Any])?["type"] as? String384 ))385 return ([(try? JSONEncoder().encode(frame)) ?? Data()], true)386387 default: // ping etc.388 return ([], false)389 }390 }391 }392}393