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