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%
17.2 KB · 393 lines swift
Raw Blame History
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