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// OpenAICompatibleClient.swift3// Zyquo Router4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// One client for every provider speaking the OpenAI /chat/completions schema:9// OpenAI, xAI, Mistral, Gemini (compat endpoint), Qwen/DashScope, DeepSeek,10// Kimi, Perplexity, Together, DeepInfra, Cerebras, and custom endpoints.11// All provider quirks live HERE — nothing leaks into ViewModels or Views.12//1314import Foundation1516struct OpenAICompatibleClient: ProviderClient {17 let providerID: ProviderID18 /// Custom endpoints override the provider's default base URL.19 var baseURLOverride: URL?2021 init(provider: ProviderID, baseURLOverride: URL? = nil) {22 self.providerID = provider23 self.baseURLOverride = baseURLOverride24 }2526 // MARK: - Wire types (requests)2728 private struct WireRequest: Encodable {29 var model: String30 var messages: [WireMessage]31 var stream: Bool?32 var streamOptions: StreamOptions?33 var temperature: Double?34 var topP: Double?35 var maxTokens: Int?36 var maxCompletionTokens: Int?37 var frequencyPenalty: Double?38 var presencePenalty: Double?39 var reasoningEffort: String?40 var enableThinking: Bool?4142 enum CodingKeys: String, CodingKey {43 case model, messages, stream, temperature44 case streamOptions = "stream_options"45 case topP = "top_p"46 case maxTokens = "max_tokens"47 case maxCompletionTokens = "max_completion_tokens"48 case frequencyPenalty = "frequency_penalty"49 case presencePenalty = "presence_penalty"50 case reasoningEffort = "reasoning_effort"51 case enableThinking = "enable_thinking"52 }53 }5455 private struct StreamOptions: Encodable {56 var includeUsage: Bool57 enum CodingKeys: String, CodingKey { case includeUsage = "include_usage" }58 }5960 private struct WireMessage: Encodable {61 var role: String62 var content: WireContent63 }6465 /// Message content: plain string, or an array of text/image parts for vision.66 private enum WireContent: Encodable {67 case text(String)68 case parts([WirePart])6970 func encode(to encoder: Encoder) throws {71 var container = encoder.singleValueContainer()72 switch self {73 case .text(let s): try container.encode(s)74 case .parts(let p): try container.encode(p)75 }76 }77 }7879 private enum WirePart: Encodable {80 case text(String)81 case imageURL(String)8283 func encode(to encoder: Encoder) throws {84 var container = encoder.container(keyedBy: DynamicKey.self)85 switch self {86 case .text(let s):87 try container.encode("text", forKey: DynamicKey("type"))88 try container.encode(s, forKey: DynamicKey("text"))89 case .imageURL(let url):90 try container.encode("image_url", forKey: DynamicKey("type"))91 var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: DynamicKey("image_url"))92 try nested.encode(url, forKey: DynamicKey("url"))93 }94 }95 }9697 private struct DynamicKey: CodingKey {98 var stringValue: String99 var intValue: Int? { nil }100 init(_ s: String) { stringValue = s }101 init?(stringValue: String) { self.stringValue = stringValue }102 init?(intValue: Int) { nil }103 }104105 // MARK: - Wire types (responses)106107 private struct WireChunk: Decodable {108 var choices: [WireChoice]?109 var usage: WireUsage?110 var citations: [String]?111 var searchResults: [WireSearchResult]?112113 enum CodingKeys: String, CodingKey {114 case choices, usage, citations115 case searchResults = "search_results"116 }117 }118119 private struct WireChoice: Decodable {120 var delta: WireDelta?121 var message: WireDelta?122 /// Together streams some models completions-style: the token text123 /// lives in `choices[].text` instead of `delta.content`.124 var text: String?125 var finishReason: String?126127 enum CodingKeys: String, CodingKey {128 case delta, message, text129 case finishReason = "finish_reason"130 }131 }132133 private struct WireDelta: Decodable {134 var content: String?135 var reasoningContent: String?136 var reasoning: String?137138 enum CodingKeys: String, CodingKey {139 case content, reasoning140 case reasoningContent = "reasoning_content"141 }142143 init(from decoder: Decoder) throws {144 let container = try decoder.container(keyedBy: CodingKeys.self)145 reasoning = try? container.decodeIfPresent(String.self, forKey: .reasoning)146 reasoningContent = try? container.decodeIfPresent(String.self, forKey: .reasoningContent)147 // `content` is normally a string, but Mistral's reasoning models148 // return an array of chunks ({type: "thinking"|"text", …}).149 if let text = try? container.decodeIfPresent(String.self, forKey: .content) {150 content = text151 } else if let chunks = try? container.decodeIfPresent([ContentChunk].self, forKey: .content) {152 var textParts: [String] = []153 var thinkingParts: [String] = []154 for chunk in chunks {155 if chunk.type == "thinking" {156 thinkingParts.append(chunk.flattenedText)157 } else {158 textParts.append(chunk.flattenedText)159 }160 }161 content = textParts.joined()162 let thinking = thinkingParts.joined()163 if !thinking.isEmpty, reasoningContent == nil {164 reasoningContent = thinking165 }166 }167 }168169 /// Mistral ThinkChunk/TextChunk: {"type":"text","text":…} or170 /// {"type":"thinking","thinking":[{"type":"text","text":…}]}.171 struct ContentChunk: Decodable {172 var type: String?173 var text: String?174 var thinking: [ContentChunkPart]?175176 var flattenedText: String {177 if let text { return text }178 return (thinking ?? []).compactMap(\.text).joined()179 }180 }181182 struct ContentChunkPart: Decodable {183 var text: String?184 }185 }186187 private struct WireUsage: Decodable {188 var promptTokens: Int?189 var completionTokens: Int?190 var completionTokensDetails: Details?191192 struct Details: Decodable {193 var reasoningTokens: Int?194 enum CodingKeys: String, CodingKey { case reasoningTokens = "reasoning_tokens" }195 }196197 enum CodingKeys: String, CodingKey {198 case promptTokens = "prompt_tokens"199 case completionTokens = "completion_tokens"200 case completionTokensDetails = "completion_tokens_details"201 }202203 var usage: TokenUsage {204 TokenUsage(205 inputTokens: promptTokens ?? 0,206 outputTokens: completionTokens ?? 0,207 reasoningTokens: completionTokensDetails?.reasoningTokens208 )209 }210 }211212 private struct WireSearchResult: Decodable {213 var title: String?214 var url: String?215 }216217 private struct WireModelList: Decodable {218 var data: [WireModelEntry]219 }220221 private struct WireModelEntry: Decodable {222 var id: String223 }224225 // MARK: - Request construction226227 private var baseURL: URL? { baseURLOverride ?? providerID.defaultBaseURL }228229 private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest {230 guard let base = baseURL else {231 throw ProviderError.invalidResponse(providerID, detail: "no base URL configured")232 }233 // Preserve base path components ("…/v1", "…/compatible-mode/v1", "…/v1beta/openai").234 var request = URLRequest(url: base.appendingPathComponent(path))235 request.httpMethod = method236 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")237 if method == "POST" {238 request.setValue("application/json", forHTTPHeaderField: "Content-Type")239 }240 return request241 }242243 /// Providers whose final streamed chunk carries usage only when asked.244 private var wantsStreamOptions: Bool {245 switch providerID {246 case .openai, .xai, .gemini, .deepseek, .kimi, .together, .cerebras, .custom:247 return true248 // Qwen, DeepInfra, Perplexity include usage automatically; Mistral249 // rejects unknown params less gracefully — omit there.250 case .mistral, .qwen, .deepinfra, .perplexity:251 return false252 case .anthropic:253 return false // never routed here254 }255 }256257 private func buildBody(_ request: ChatRequest) throws -> Data {258 var messages: [WireMessage] = []259 if let system = request.systemPrompt, !system.isEmpty {260 messages.append(WireMessage(role: "system", content: .text(system)))261 }262 for message in request.messages where message.role != .system {263 messages.append(wireMessage(from: message, vision: request.model.capabilities.vision))264 }265266 let support = request.model.parameterSupport267 let params = request.parameters268 var wire = WireRequest(model: request.model.id, messages: messages)269 if request.stream {270 wire.stream = true271 if wantsStreamOptions {272 wire.streamOptions = StreamOptions(includeUsage: true)273 }274 }275 if support.temperature { wire.temperature = params.temperature }276 if support.topP { wire.topP = params.topP }277 if let max = params.maxTokens {278 if support.usesMaxCompletionTokens {279 wire.maxCompletionTokens = max280 } else {281 wire.maxTokens = max282 }283 }284 if support.frequencyPenalty { wire.frequencyPenalty = params.frequencyPenalty }285 if support.presencePenalty { wire.presencePenalty = params.presencePenalty }286 if support.reasoningEffort {287 // Mistral only accepts "high"/"none": map medium→high, low→none.288 if providerID == .mistral, let effort = params.reasoningEffort {289 wire.reasoningEffort = effort == "low" ? "none" : "high"290 } else {291 wire.reasoningEffort = params.reasoningEffort292 }293 }294 if support.thinkingToggle, providerID == .qwen {295 // DashScope: enable_thinking is only legal on streaming requests.296 if request.stream { wire.enableThinking = params.thinkingEnabled }297 }298 let encoder = JSONEncoder()299 return try encoder.encode(wire)300 }301302 private func wireMessage(from message: Message, vision: Bool) -> WireMessage {303 let role = message.role == .assistant ? "assistant" : "user"304 var text = message.text305 // Text-file attachments are injected inline, fenced with the file name.306 for attachment in message.attachments where attachment.kind == .textFile {307 let contents = String(data: attachment.data, encoding: .utf8) ?? ""308 text += "\n\n```\(attachment.fileName)\n\(contents)\n```"309 }310 let images = message.attachments.filter { $0.kind == .image }311 guard vision, !images.isEmpty, message.role == .user else {312 return WireMessage(role: role, content: .text(text))313 }314 var parts: [WirePart] = [.text(text)]315 for image in images {316 let dataURI = "data:\(image.mimeType);base64,\(image.data.base64EncodedString())"317 parts.append(.imageURL(dataURI))318 }319 return WireMessage(role: role, content: .parts(parts))320 }321322 // MARK: - ProviderClient323324 func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {325 AsyncThrowingStream { continuation in326 let task = Task {327 do {328 var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)329 var streamRequest = request330 streamRequest.stream = true331 urlReq.httpBody = try buildBody(streamRequest)332333 var citationsSent = false334 var finishReason: String?335 let decoder = JSONDecoder()336337 for try await event in StreamingService.sseEvents(for: urlReq, provider: providerID) {338 if event.data == "[DONE]" { break }339 guard let data = event.data.data(using: .utf8),340 let chunk = try? decoder.decode(WireChunk.self, from: data) else {341 continue // tolerate unknown/malformed keep-alive chunks342 }343 if let choice = chunk.choices?.first {344 if let reasoning = choice.delta?.reasoningContent ?? choice.delta?.reasoning,345 !reasoning.isEmpty {346 continuation.yield(.reasoningDelta(reasoning))347 }348 let deltaText = choice.delta?.content ?? choice.text349 if let deltaText, !deltaText.isEmpty {350 continuation.yield(.textDelta(deltaText))351 }352 if let reason = choice.finishReason {353 finishReason = reason354 }355 }356 if !citationsSent, let citations = Self.citations(from: chunk), !citations.isEmpty {357 citationsSent = true358 continuation.yield(.citations(citations))359 }360 if let usage = chunk.usage {361 continuation.yield(.usage(usage.usage))362 }363 }364 continuation.yield(.finished(reason: finishReason))365 continuation.finish()366 } catch {367 continuation.finish(throwing: error)368 }369 }370 continuation.onTermination = { _ in task.cancel() }371 }372 }373374 func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {375 // Some models reject non-streaming calls — aggregate a stream instead.376 if request.model.parameterSupport.requiresStreaming {377 return try await completeViaStream(request, apiKey: apiKey)378 }379 var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)380 var plainRequest = request381 plainRequest.stream = false382 urlReq.httpBody = try buildBody(plainRequest)383 let data = try await StreamingService.postJSON(urlReq, provider: providerID)384 let chunk = try decodeOrThrow(WireChunk.self, from: data)385 guard let choice = chunk.choices?.first, let content = choice.message ?? choice.delta else {386 throw ProviderError.invalidResponse(providerID, detail: "response contained no message")387 }388 var message = Message(389 role: .assistant,390 text: content.content ?? choice.text ?? "",391 reasoning: content.reasoningContent ?? content.reasoning,392 modelID: request.model.id,393 provider: providerID394 )395 if let citations = Self.citations(from: chunk) {396 message.citations = citations397 }398 if let usage = chunk.usage?.usage {399 message.usage = usage400 message.estimatedCost = request.model.pricing?.cost(401 inputTokens: usage.inputTokens, outputTokens: usage.outputTokens402 )403 }404 return message405 }406407 func listModelIDs(apiKey: String) async throws -> [String] {408 let urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET")409 let data = try await StreamingService.getJSON(urlReq, provider: providerID)410 // Together returns a bare array; everyone else wraps in {"data": […]}.411 // Gemini's compat endpoint prefixes IDs with "models/" — normalize.412 let ids: [String]413 if let list = try? JSONDecoder().decode(WireModelList.self, from: data) {414 ids = list.data.map(\.id)415 } else if let bare = try? JSONDecoder().decode([WireModelEntry].self, from: data) {416 ids = bare.map(\.id)417 } else {418 throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape")419 }420 return ids.map { $0.hasPrefix("models/") ? String($0.dropFirst(7)) : $0 }421 }422423 /// Non-streaming result assembled from the streaming endpoint, for models424 /// that only support `stream: true`.425 private func completeViaStream(_ request: ChatRequest, apiKey: String) async throws -> Message {426 var text = ""427 var reasoning = ""428 var citations: [Citation] = []429 var usage: TokenUsage?430 for try await event in streamChat(request, apiKey: apiKey) {431 switch event {432 case .textDelta(let delta): text += delta433 case .reasoningDelta(let delta): reasoning += delta434 case .citations(let c): citations = c435 case .usage(let u): usage = u436 case .finished: break437 }438 }439 var message = Message(440 role: .assistant,441 text: text,442 reasoning: reasoning.isEmpty ? nil : reasoning,443 citations: citations,444 modelID: request.model.id,445 provider: providerID446 )447 if let usage {448 message.usage = usage449 message.estimatedCost = request.model.pricing?.cost(450 inputTokens: usage.inputTokens, outputTokens: usage.outputTokens451 )452 }453 return message454 }455456 // MARK: - Helpers457458 private func decodeOrThrow<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {459 do {460 return try JSONDecoder().decode(type, from: data)461 } catch {462 throw ProviderError.invalidResponse(providerID, detail: "decode failed: \(error.localizedDescription)")463 }464 }465466 /// Perplexity: `citations` is an array of URL strings; `search_results`467 /// adds titles. Merge both into numbered citations.468 private static func citations(from chunk: WireChunk) -> [Citation]? {469 guard let urls = chunk.citations, !urls.isEmpty else { return nil }470 let titles = chunk.searchResults ?? []471 return urls.enumerated().compactMap { index, urlString in472 guard let url = URL(string: urlString) else { return nil }473 let title = index < titles.count ? titles[index].title : nil474 return Citation(index: index + 1, url: url, title: title)475 }476 }477}478