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%
12.8 KB · 308 lines swift
Raw Blame History
1//2//  CompatAdjuster.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Per-provider request/response adjustment for the OpenAI-compatible9//  upstreams (everything except Anthropic and Gemini, which translate fully).10//  Tables from docs/ROUTER-RESEARCH.md §3.3: strip what 400s, rename what11//  differs, clamp what has narrower ranges, pass unknown keys through (D5),12//  and normalize response quirks into the OpenAI contract.13//1415import Foundation1617enum CompatAdjuster {18    // MARK: - Request adjustment1920    /// Rewrites an inbound OpenAI request body for a specific compat upstream.21    static func adjustRequest(22        _ original: [String: Any],23        model: AIModel,24        stream: Bool25    ) -> [String: Any] {26        var body = original27        let provider = model.provider2829        // Bare upstream model id (namespace/alias resolution already happened).30        body["model"] = model.id3132        // max_tokens naming: send whichever the model documents, never both.33        let maxTokens = (body["max_completion_tokens"] as? Int) ?? (body["max_tokens"] as? Int)34        body["max_tokens"] = nil35        body["max_completion_tokens"] = nil36        if let maxTokens {37            body[model.parameterSupport.usesMaxCompletionTokens ? "max_completion_tokens" : "max_tokens"] = maxTokens38        }3940        // stream/stream_options are router-controlled.41        body["stream"] = stream42        body["stream_options"] = nil43        if stream, supportsStreamUsage(provider) {44            body["stream_options"] = ["include_usage": true]45        }4647        // Parameters the catalog says this model rejects.48        if !model.parameterSupport.temperature { body["temperature"] = nil }49        if !model.parameterSupport.topP { body["top_p"] = nil }50        if !model.parameterSupport.frequencyPenalty { body["frequency_penalty"] = nil }51        if !model.parameterSupport.presencePenalty { body["presence_penalty"] = nil }52        // reasoning_effort goes upstream only when the model accepts the53        // param (some reasoning models 400 on it — Grok 4.20, grok-code).54        // Mistral/Qwen re-map it below before this strip matters.55        if !model.capabilities.reasoning { body["reasoning_effort"] = nil }56        if !model.parameterSupport.reasoningEffort, provider != .mistral, provider != .qwen {57            body["reasoning_effort"] = nil58        }5960        // Echo-back hygiene: strip reasoning_content/reasoning_details from61        // incoming assistant messages (wastes tokens; DeepSeek 400s) — except62        // DeepSeek tool-call loops, which REQUIRE reasoning_content back.63        let keepReasoning = provider == .deepseek && body["tools"] != nil64        if !keepReasoning, var messages = body["messages"] as? [[String: Any]] {65            for index in messages.indices where messages[index]["role"] as? String == "assistant" {66                messages[index]["reasoning_content"] = nil67                messages[index]["reasoning_details"] = nil68            }69            body["messages"] = messages70        }7172        switch provider {73        case .openai:74            // gpt-5.6 family: tools + (implicit) reasoning are rejected on75            // /v1/chat/completions; an explicit reasoning_effort "none"76            // unlocks tool calling (verified Phase 7).77            if model.id.hasPrefix("gpt-5.6"), body["tools"] != nil, body["reasoning_effort"] == nil {78                body["reasoning_effort"] = "none"79            }80        case .xai:81            if model.capabilities.reasoning {82                // Grok reasoning models 400 on these instead of ignoring them.83                body["presence_penalty"] = nil84                body["frequency_penalty"] = nil85                body["stop"] = nil86            }87        case .mistral:88            if let seed = body["seed"] { body["random_seed"] = seed; body["seed"] = nil }89            body["logit_bias"] = nil90            body["user"] = nil91            body["logprobs"] = nil92            // Mistral reasoning: models with native reasoning_effort accept93            // only none|high; Magistral-style models use prompt_mode instead.94            if let effort = body["reasoning_effort"] as? String {95                if model.parameterSupport.reasoningEffort {96                    body["reasoning_effort"] = effort == "none" ? "none" : "high"97                } else {98                    body["reasoning_effort"] = nil99                    if body["prompt_mode"] == nil { body["prompt_mode"] = "reasoning" }100                }101            }102        case .qwen:103            body["logit_bias"] = nil104            // Hybrid-thinking models think by default; honor the client's ask.105            if model.parameterSupport.thinkingToggle, body["enable_thinking"] == nil {106                body["enable_thinking"] = body["reasoning_effort"] != nil107                body["reasoning_effort"] = nil108            }109        case .deepseek:110            body["logprobs"] = nil111            body["top_logprobs"] = nil112        case .kimi:113            if let temperature = body["temperature"] as? Double {114                body["temperature"] = min(max(temperature, 0), 1)115            }116        case .deepinfra:117            body["logit_bias"] = nil118        case .perplexity, .together, .cerebras, .anthropic, .gemini, .custom:119            break120        }121        return body122    }123124    /// Models whose non-streaming upstream endpoint is broken or times out125    /// while streaming works (Phase 7 findings) — the router transparently126    /// streams upstream and aggregates for buffered clients.127    static func requiresStreamingOverride(_ model: AIModel) -> Bool {128        switch (model.provider, model.id) {129        case (.deepinfra, "google/gemini-2.5-pro"),130             (.gemini, "gemini-3.1-pro-preview"),131             (.together, "Qwen/Qwen3.5-9B"):132            return true133        default:134            return false135        }136    }137138    /// Providers that honor `stream_options.include_usage` (research §3.3).139    static func supportsStreamUsage(_ provider: ProviderID) -> Bool {140        switch provider {141        case .openai, .xai, .mistral, .qwen, .deepseek, .kimi, .together, .deepinfra, .cerebras:142            return true143        case .perplexity, .anthropic, .gemini, .custom:144            return false145        }146    }147148    // MARK: - Response normalization (non-streaming)149150    /// Normalizes a compat upstream's `chat.completion` into the router151    /// contract: namespaced model echo, canonical finish_reason, flattened152    /// reasoning, usage detail mapping. Unknown fields pass through.153    static func normalizeResponse(154        _ original: [String: Any],155        namespacedModel: String,156        provider: ProviderID157    ) -> [String: Any] {158        var body = original159        body["model"] = namespacedModel160161        if var choices = body["choices"] as? [[String: Any]] {162            for index in choices.indices {163                if let finish = choices[index]["finish_reason"] as? String {164                    choices[index]["finish_reason"] = normalizeFinishReason(finish)165                }166                if var message = choices[index]["message"] as? [String: Any] {167                    normalizeMessage(&message, provider: provider)168                    choices[index]["message"] = message169                }170                // Perplexity leaks a `delta` field into non-streaming choices.171                if provider == .perplexity { choices[index]["delta"] = nil }172            }173            body["choices"] = choices174        }175176        if var usage = body["usage"] as? [String: Any] {177            normalizeUsage(&usage, provider: provider)178            body["usage"] = usage179        }180        return body181    }182183    /// Normalizes one streamed chunk in place. Returns nil for chunks the184    /// router should swallow (e.g. usage chunk when the client didn't ask).185    static func normalizeChunk(186        _ original: [String: Any],187        namespacedModel: String,188        provider: ProviderID,189        clientWantsUsage: Bool190    ) -> [String: Any]? {191        var chunk = original192        chunk["model"] = namespacedModel193194        // Perplexity terminates streams with a non-spec summary event195        // (object: "chat.completion.done") — swallow it, keeping its usage.196        if let object = chunk["object"] as? String, object != "chat.completion.chunk" {197            return nil198        }199        // Some hosted models omit the object field entirely (DeepInfra-hosted200        // Gemini) — strict SDKs require it.201        if chunk["object"] == nil {202            chunk["object"] = "chat.completion.chunk"203        }204205        if var choices = chunk["choices"] as? [[String: Any]] {206            for index in choices.indices {207                if let finish = choices[index]["finish_reason"] as? String {208                    choices[index]["finish_reason"] = normalizeFinishReason(finish)209                }210                if var delta = choices[index]["delta"] as? [String: Any] {211                    normalizeMessage(&delta, provider: provider)212                    choices[index]["delta"] = delta213                }214            }215            chunk["choices"] = choices216217            // Usage-only chunk (empty choices): swallow unless requested.218            if choices.isEmpty, chunk["usage"] != nil, !clientWantsUsage {219                return nil220            }221        }222223        if var usage = chunk["usage"] as? [String: Any] {224            normalizeUsage(&usage, provider: provider)225            chunk["usage"] = usage226        }227        return chunk228    }229230    // MARK: - Shared pieces231232    /// Everything lands in the OpenAI closed set (research §3.3 rule 4).233    static func normalizeFinishReason(_ raw: String) -> String {234        switch raw {235        case "stop", "length", "tool_calls", "content_filter", "function_call":236            return raw237        case "eos":238            return "stop"239        case "max_tokens", "model_length":240            return "length"241        case "safety", "recitation":242            return "content_filter"243        default:244            return "stop"245        }246    }247248    /// Message/delta-level quirks: Mistral thinking arrays, Perplexity249    /// <think> tags, Together text field.250    private static func normalizeMessage(_ message: inout [String: Any], provider: ProviderID) {251        switch provider {252        case .mistral:253            // Magistral: content is an ARRAY of {type:"thinking"|"text"} chunks.254            if let parts = message["content"] as? [[String: Any]] {255                var text = ""256                var reasoning = message["reasoning_content"] as? String ?? ""257                for part in parts {258                    switch part["type"] as? String {259                    case "text":260                        text += part["text"] as? String ?? ""261                    case "thinking":262                        for inner in part["thinking"] as? [[String: Any]] ?? [] {263                            reasoning += inner["text"] as? String ?? ""264                        }265                    default:266                        break267                    }268                }269                message["content"] = text270                if !reasoning.isEmpty { message["reasoning_content"] = reasoning }271            }272        case .perplexity:273            // sonar-reasoning embeds <think>…</think> in content.274            if let content = message["content"] as? String,275               content.hasPrefix("<think>"),276               let closeRange = content.range(of: "</think>") {277                let reasoning = String(content[content.index(content.startIndex, offsetBy: 7)..<closeRange.lowerBound])278                message["reasoning_content"] = reasoning279                message["content"] = String(content[closeRange.upperBound...])280                    .trimmingCharacters(in: .whitespacesAndNewlines)281            }282        case .together:283            // Some Together models put streamed text in choices[].text.284            if message["content"] == nil, let text = message["text"] as? String {285                message["content"] = text286                message["text"] = nil287            }288        default:289            break290        }291    }292293    /// Provider usage extras → OpenAI detail objects.294    private static func normalizeUsage(_ usage: inout [String: Any], provider: ProviderID) {295        if provider == .deepseek, let cacheHit = usage["prompt_cache_hit_tokens"] as? Int, cacheHit > 0 {296            var details = usage["prompt_tokens_details"] as? [String: Any] ?? [:]297            if details["cached_tokens"] == nil { details["cached_tokens"] = cacheHit }298            usage["prompt_tokens_details"] = details299        }300        if let reasoningTokens = usage["reasoning_tokens"] as? Int, reasoningTokens > 0 {301            var details = usage["completion_tokens_details"] as? [String: Any] ?? [:]302            if details["reasoning_tokens"] == nil { details["reasoning_tokens"] = reasoningTokens }303            usage["completion_tokens_details"] = details304            usage["reasoning_tokens"] = nil305        }306    }307}308