SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
18.8 KB · 478 lines swift
Raw Blame History
1//2//  OpenAICompatibleClient.swift3//  Zyquo Atlas4//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