SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
29.6 KB · 726 lines swift
Raw Blame History
1//2//  OpenAICompatibleClient.swift3//  Zyquo Agent4//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//13//  Ported from Zyquo Cloud; Zyquo Agent adds native tool calling14//  (docs/PROVIDER-REUSE.md §5.2): `tools` [{type:"function",function:{…}}] +15//  `tool_choice` on requests, index-keyed accumulation of streaming16//  `delta.tool_calls` fragments, finish_reason "tool_calls" vs "stop", and17//  threading of assistant `tool_calls` + role:"tool" messages from history.18//1920import Foundation2122struct OpenAICompatibleClient: ProviderClient {23    let providerID: ProviderID24    /// Custom endpoints override the provider's default base URL.25    var baseURLOverride: URL?2627    init(provider: ProviderID, baseURLOverride: URL? = nil) {28        self.providerID = provider29        self.baseURLOverride = baseURLOverride30    }3132    // MARK: - Wire types (requests)3334    private struct WireRequest: Encodable {35        var model: String36        var messages: [WireMessage]37        var stream: Bool?38        var streamOptions: StreamOptions?39        var temperature: Double?40        var topP: Double?41        var maxTokens: Int?42        var maxCompletionTokens: Int?43        var frequencyPenalty: Double?44        var presencePenalty: Double?45        var reasoningEffort: String?46        var enableThinking: Bool?47        var tools: [WireTool]?48        var toolChoice: WireToolChoice?4950        enum CodingKeys: String, CodingKey {51            case model, messages, stream, temperature, tools52            case streamOptions = "stream_options"53            case topP = "top_p"54            case maxTokens = "max_tokens"55            case maxCompletionTokens = "max_completion_tokens"56            case frequencyPenalty = "frequency_penalty"57            case presencePenalty = "presence_penalty"58            case reasoningEffort = "reasoning_effort"59            case enableThinking = "enable_thinking"60            case toolChoice = "tool_choice"61        }62    }6364    private struct StreamOptions: Encodable {65        var includeUsage: Bool66        enum CodingKeys: String, CodingKey { case includeUsage = "include_usage" }67    }6869    /// `tools: [{"type":"function","function":{name, description, parameters}}]`.70    private struct WireTool: Encodable {71        var type = "function"72        var function: Function7374        struct Function: Encodable {75            var name: String76            var description: String77            var parameters: JSONValue78        }79    }8081    /// `tool_choice`: "none"/"auto"/"required" or {"type":"function","function":{"name":…}}.82    private enum WireToolChoice: Encodable {83        case mode(String)84        case function(String)8586        func encode(to encoder: Encoder) throws {87            switch self {88            case .mode(let mode):89                var container = encoder.singleValueContainer()90                try container.encode(mode)91            case .function(let name):92                var container = encoder.container(keyedBy: DynamicKey.self)93                try container.encode("function", forKey: DynamicKey("type"))94                var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: DynamicKey("function"))95                try nested.encode(name, forKey: DynamicKey("name"))96            }97        }98    }99100    private struct WireMessage: Encodable {101        var role: String102        var content: WireContent?103        /// Assistant turns that called tools carry them back verbatim.104        var toolCalls: [WireToolCallOut]?105        /// role:"tool" messages reference the call they answer.106        var toolCallID: String?107108        enum CodingKeys: String, CodingKey {109            case role, content110            case toolCalls = "tool_calls"111            case toolCallID = "tool_call_id"112        }113    }114115    /// An assistant tool call echoed back into history. `extraContent` carries116    /// Gemini 3+ thought signatures (`extra_content.google.thought_signature`)117    /// back verbatim — Gemini rejects threaded tool results without them.118    private struct WireToolCallOut: Encodable {119        var id: String120        var type = "function"121        var function: Function122        var extraContent: ExtraContent?123124        enum CodingKeys: String, CodingKey {125            case id, type, function126            case extraContent = "extra_content"127        }128129        struct Function: Encodable {130            var name: String131            var arguments: String132        }133134        struct ExtraContent: Codable {135            var google: Google136137            struct Google: Codable {138                var thoughtSignature: String?139                enum CodingKeys: String, CodingKey {140                    case thoughtSignature = "thought_signature"141                }142            }143        }144    }145146    /// Message content: plain string, or an array of text/image parts for vision.147    private enum WireContent: Encodable {148        case text(String)149        case parts([WirePart])150151        func encode(to encoder: Encoder) throws {152            var container = encoder.singleValueContainer()153            switch self {154            case .text(let s): try container.encode(s)155            case .parts(let p): try container.encode(p)156            }157        }158    }159160    private enum WirePart: Encodable {161        case text(String)162        case imageURL(String)163164        func encode(to encoder: Encoder) throws {165            var container = encoder.container(keyedBy: DynamicKey.self)166            switch self {167            case .text(let s):168                try container.encode("text", forKey: DynamicKey("type"))169                try container.encode(s, forKey: DynamicKey("text"))170            case .imageURL(let url):171                try container.encode("image_url", forKey: DynamicKey("type"))172                var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: DynamicKey("image_url"))173                try nested.encode(url, forKey: DynamicKey("url"))174            }175        }176    }177178    private struct DynamicKey: CodingKey {179        var stringValue: String180        var intValue: Int? { nil }181        init(_ s: String) { stringValue = s }182        init?(stringValue: String) { self.stringValue = stringValue }183        init?(intValue: Int) { nil }184    }185186    // MARK: - Wire types (responses)187188    private struct WireChunk: Decodable {189        var choices: [WireChoice]?190        var usage: WireUsage?191        var citations: [String]?192        var searchResults: [WireSearchResult]?193194        enum CodingKeys: String, CodingKey {195            case choices, usage, citations196            case searchResults = "search_results"197        }198    }199200    private struct WireChoice: Decodable {201        var delta: WireDelta?202        var message: WireDelta?203        /// Together streams some models completions-style: the token text204        /// lives in `choices[].text` instead of `delta.content`.205        var text: String?206        var finishReason: String?207208        enum CodingKeys: String, CodingKey {209            case delta, message, text210            case finishReason = "finish_reason"211        }212    }213214    private struct WireDelta: Decodable {215        var content: String?216        var reasoningContent: String?217        var reasoning: String?218        /// Streaming: index-keyed fragments. Non-streaming: complete calls.219        var toolCalls: [WireToolCallDelta]?220221        enum CodingKeys: String, CodingKey {222            case content, reasoning223            case reasoningContent = "reasoning_content"224            case toolCalls = "tool_calls"225        }226227        init(from decoder: Decoder) throws {228            let container = try decoder.container(keyedBy: CodingKeys.self)229            reasoning = try? container.decodeIfPresent(String.self, forKey: .reasoning)230            reasoningContent = try? container.decodeIfPresent(String.self, forKey: .reasoningContent)231            toolCalls = try? container.decodeIfPresent([WireToolCallDelta].self, forKey: .toolCalls)232            // `content` is normally a string, but Mistral's reasoning models233            // return an array of chunks ({type: "thinking"|"text", …}).234            if let text = try? container.decodeIfPresent(String.self, forKey: .content) {235                content = text236            } else if let chunks = try? container.decodeIfPresent([ContentChunk].self, forKey: .content) {237                var textParts: [String] = []238                var thinkingParts: [String] = []239                for chunk in chunks {240                    if chunk.type == "thinking" {241                        thinkingParts.append(chunk.flattenedText)242                    } else {243                        textParts.append(chunk.flattenedText)244                    }245                }246                content = textParts.joined()247                let thinking = thinkingParts.joined()248                if !thinking.isEmpty, reasoningContent == nil {249                    reasoningContent = thinking250                }251            }252        }253254        /// Mistral ThinkChunk/TextChunk: {"type":"text","text":…} or255        /// {"type":"thinking","thinking":[{"type":"text","text":…}]}.256        struct ContentChunk: Decodable {257            var type: String?258            var text: String?259            var thinking: [ContentChunkPart]?260261            var flattenedText: String {262                if let text { return text }263                return (thinking ?? []).compactMap(\.text).joined()264            }265        }266267        struct ContentChunkPart: Decodable {268            var text: String?269        }270    }271272    /// One `delta.tool_calls[]` fragment (streaming) or `message.tool_calls[]`273    /// entry (non-streaming). The first fragment for an index carries id +274    /// function.name; subsequent ones carry function.arguments chunks.275    private struct WireToolCallDelta: Decodable {276        var index: Int?277        var id: String?278        var function: FunctionFragment?279        /// Gemini 3+ attaches `extra_content.google.thought_signature` to the280        /// call (both streaming fragments and non-streaming entries).281        var extraContent: WireToolCallOut.ExtraContent?282283        enum CodingKeys: String, CodingKey {284            case index, id, function285            case extraContent = "extra_content"286        }287288        struct FunctionFragment: Decodable {289            var name: String?290            var arguments: String?291        }292    }293294    private struct WireUsage: Decodable {295        var promptTokens: Int?296        var completionTokens: Int?297        var completionTokensDetails: Details?298299        struct Details: Decodable {300            var reasoningTokens: Int?301            enum CodingKeys: String, CodingKey { case reasoningTokens = "reasoning_tokens" }302        }303304        enum CodingKeys: String, CodingKey {305            case promptTokens = "prompt_tokens"306            case completionTokens = "completion_tokens"307            case completionTokensDetails = "completion_tokens_details"308        }309310        var usage: TokenUsage {311            TokenUsage(312                inputTokens: promptTokens ?? 0,313                outputTokens: completionTokens ?? 0,314                reasoningTokens: completionTokensDetails?.reasoningTokens315            )316        }317    }318319    private struct WireSearchResult: Decodable {320        var title: String?321        var url: String?322    }323324    private struct WireModelList: Decodable {325        var data: [WireModelEntry]326    }327328    private struct WireModelEntry: Decodable {329        var id: String330    }331332    // MARK: - Request construction333334    private var baseURL: URL? { baseURLOverride ?? providerID.defaultBaseURL }335336    private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest {337        guard let base = baseURL else {338            throw ProviderError.invalidResponse(providerID, detail: "no base URL configured")339        }340        // Preserve base path components ("…/v1", "…/compatible-mode/v1", "…/v1beta/openai").341        var request = URLRequest(url: base.appendingPathComponent(path))342        request.httpMethod = method343        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")344        if method == "POST" {345            request.setValue("application/json", forHTTPHeaderField: "Content-Type")346        }347        return request348    }349350    /// Providers whose final streamed chunk carries usage only when asked.351    private var wantsStreamOptions: Bool {352        switch providerID {353        case .openai, .xai, .gemini, .deepseek, .kimi, .together, .cerebras, .custom:354            return true355        // Qwen, DeepInfra, Perplexity include usage automatically; Mistral356        // rejects unknown params less gracefully — omit there.357        case .mistral, .qwen, .deepinfra, .perplexity:358            return false359        case .anthropic:360            return false // never routed here361        }362    }363364    private func buildBody(_ request: ChatRequest) throws -> Data {365        var messages: [WireMessage] = []366        if let system = request.systemPrompt, !system.isEmpty {367            messages.append(WireMessage(role: "system", content: .text(system)))368        }369        for message in request.messages where message.role != .system {370            messages.append(contentsOf: wireMessages(from: message, vision: request.model.capabilities.vision))371        }372373        let support = request.model.parameterSupport374        let params = request.parameters375        var wire = WireRequest(model: request.model.id, messages: messages)376        if request.stream {377            wire.stream = true378            if wantsStreamOptions {379                wire.streamOptions = StreamOptions(includeUsage: true)380            }381        }382        if support.temperature { wire.temperature = params.temperature }383        if support.topP { wire.topP = params.topP }384        if let max = params.maxTokens {385            if support.usesMaxCompletionTokens {386                wire.maxCompletionTokens = max387            } else {388                wire.maxTokens = max389            }390        }391        if support.frequencyPenalty { wire.frequencyPenalty = params.frequencyPenalty }392        if support.presencePenalty { wire.presencePenalty = params.presencePenalty }393        if support.reasoningEffort {394            // Mistral only accepts "high"/"none": map medium→high, low→none.395            if providerID == .mistral, let effort = params.reasoningEffort {396                wire.reasoningEffort = effort == "low" ? "none" : "high"397            } else {398                wire.reasoningEffort = params.reasoningEffort399            }400        }401        if support.thinkingToggle, providerID == .qwen {402            // DashScope: enable_thinking is only legal on streaming requests.403            if request.stream { wire.enableThinking = params.thinkingEnabled }404        }405        // Native tool calling (only for models that support it — providers406        // reject `tools` on non-tool models).407        if !request.tools.isEmpty, request.model.capabilities.tools {408            // OpenAI GPT-5.4+ rejects function tools combined with any409            // reasoning_effort other than "none" on /chat/completions410            // ("Function tools with reasoning_effort are not supported …411            // set reasoning_effort to 'none'" — verified live, Phase 7.1).412            // GPT-5.2 and earlier accept both together.413            if providerID == .openai, wire.reasoningEffort != nil,414               ["gpt-5.4", "gpt-5.5", "gpt-5.6"].contains(where: request.model.id.hasPrefix) {415                wire.reasoningEffort = "none"416            }417            wire.tools = request.tools.map { spec in418                WireTool(function: WireTool.Function(419                    name: spec.name,420                    description: spec.description,421                    parameters: JSONValue.parse(spec.parametersJSONSchema) ?? .emptyObject422                ))423            }424            switch request.toolChoice {425            case .auto:426                break // provider default — omit for maximum compatibility427            case .none:428                wire.toolChoice = .mode("none")429            case .required:430                wire.toolChoice = .mode("required")431            case .named(let name):432                wire.toolChoice = .function(name)433            }434        }435        let encoder = JSONEncoder()436        return try encoder.encode(wire)437    }438439    /// One transcript Message → one or more wire messages. Tool results expand440    /// to one role:"tool" message per result; assistant turns carry their441    /// `tool_calls` back verbatim so multi-step tool history round-trips.442    private func wireMessages(from message: Message, vision: Bool) -> [WireMessage] {443        if let results = message.toolResults, !results.isEmpty {444            return results.map { result in445                WireMessage(role: "tool", content: .text(result.content), toolCallID: result.toolCallID)446            }447        }448        let role = message.role == .assistant ? "assistant" : "user"449        var text = message.text450        // Text-file attachments are injected inline, fenced with the file name.451        for attachment in message.attachments where attachment.kind == .textFile {452            let contents = String(data: attachment.data, encoding: .utf8) ?? ""453            text += "\n\n```\(attachment.fileName)\n\(contents)\n```"454        }455        let toolCallsOut: [WireToolCallOut]? = message.toolCalls.flatMap { calls in456            calls.isEmpty ? nil : calls.map { call in457                WireToolCallOut(458                    id: call.id,459                    function: WireToolCallOut.Function(name: call.name, arguments: call.argumentsJSON),460                    // Echo Gemini 3+ thought signatures back verbatim; other461                    // providers never set one and never receive the field.462                    extraContent: call.thoughtSignature.map {463                        WireToolCallOut.ExtraContent(464                            google: WireToolCallOut.ExtraContent.Google(thoughtSignature: $0)465                        )466                    }467                )468            }469        }470        let images = message.attachments.filter { $0.kind == .image }471        guard vision, !images.isEmpty, message.role == .user else {472            // Assistant tool-call turns may have empty text — omit content then.473            let content: WireContent? = (text.isEmpty && toolCallsOut != nil) ? nil : .text(text)474            return [WireMessage(role: role, content: content, toolCalls: toolCallsOut)]475        }476        var parts: [WirePart] = [.text(text)]477        for image in images {478            let dataURI = "data:\(image.mimeType);base64,\(image.data.base64EncodedString())"479            parts.append(.imageURL(dataURI))480        }481        return [WireMessage(role: role, content: .parts(parts), toolCalls: toolCallsOut)]482    }483484    // MARK: - Streaming tool-call accumulation485486    /// Accumulates index-keyed `delta.tool_calls` fragments into complete calls.487    private struct ToolCallAccumulator {488        private struct Partial {489            var id: String?490            var name: String?491            var arguments = ""492            var thoughtSignature: String?493            var announced = false494        }495496        private var partials: [Int: Partial] = [:]497498        var isEmpty: Bool { partials.isEmpty }499500        /// Consumes one fragment; returns events to yield (started/arguments deltas).501        mutating func consume(_ fragments: [WireToolCallDelta]) -> [ChatEvent] {502            var events: [ChatEvent] = []503            for fragment in fragments {504                let index = fragment.index ?? partials.keys.max() ?? 0505                var partial = partials[index] ?? Partial()506                if let id = fragment.id, !id.isEmpty { partial.id = id }507                if let name = fragment.function?.name, !name.isEmpty {508                    partial.name = (partial.name ?? "") + name509                }510                if let signature = fragment.extraContent?.google.thoughtSignature, !signature.isEmpty {511                    partial.thoughtSignature = signature512                }513                if !partial.announced, let name = partial.name {514                    partial.announced = true515                    if partial.id == nil {516                        // Providers occasionally omit ids — synthesize a stable one.517                        partial.id = "call_\(index)_\(UUID().uuidString.prefix(8))"518                    }519                    events.append(.toolCallStarted(index: index, id: partial.id ?? "", name: name))520                }521                if let args = fragment.function?.arguments, !args.isEmpty {522                    partial.arguments += args523                    events.append(.toolCallArgumentsDelta(index: index, delta: args))524                }525                partials[index] = partial526            }527            return events528        }529530        /// The finalized calls in index order.531        func finalized() -> [ToolCall] {532            partials.sorted { $0.key < $1.key }.map { index, partial in533                ToolCall(534                    id: partial.id ?? "call_\(index)_\(UUID().uuidString.prefix(8))",535                    name: partial.name ?? "",536                    argumentsJSON: partial.arguments.isEmpty ? "{}" : partial.arguments,537                    thoughtSignature: partial.thoughtSignature538                )539            }540        }541    }542543    // MARK: - ProviderClient544545    func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {546        AsyncThrowingStream { continuation in547            let task = Task {548                do {549                    var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)550                    var streamRequest = request551                    streamRequest.stream = true552                    urlReq.httpBody = try buildBody(streamRequest)553554                    var citationsSent = false555                    var finishReason: String?556                    var accumulator = ToolCallAccumulator()557                    let decoder = JSONDecoder()558559                    for try await event in StreamingService.sseEvents(for: urlReq, provider: providerID) {560                        if event.data == "[DONE]" { break }561                        guard let data = event.data.data(using: .utf8),562                              let chunk = try? decoder.decode(WireChunk.self, from: data) else {563                            continue // tolerate unknown/malformed keep-alive chunks564                        }565                        if let choice = chunk.choices?.first {566                            if let reasoning = choice.delta?.reasoningContent ?? choice.delta?.reasoning,567                               !reasoning.isEmpty {568                                continuation.yield(.reasoningDelta(reasoning))569                            }570                            let deltaText = choice.delta?.content ?? choice.text571                            if let deltaText, !deltaText.isEmpty {572                                continuation.yield(.textDelta(deltaText))573                            }574                            if let fragments = choice.delta?.toolCalls, !fragments.isEmpty {575                                for toolEvent in accumulator.consume(fragments) {576                                    continuation.yield(toolEvent)577                                }578                            }579                            if let reason = choice.finishReason {580                                finishReason = reason581                            }582                        }583                        if !citationsSent, let citations = Self.citations(from: chunk), !citations.isEmpty {584                            citationsSent = true585                            continuation.yield(.citations(citations))586                        }587                        if let usage = chunk.usage {588                            continuation.yield(.usage(usage.usage))589                        }590                    }591                    let calls = accumulator.finalized()592                    if !calls.isEmpty {593                        continuation.yield(.toolCalls(calls))594                    }595                    continuation.yield(.finished(596                        reason: finishReason,597                        stop: StopReason.normalize(finishReason, hasToolCalls: !calls.isEmpty)598                    ))599                    continuation.finish()600                } catch {601                    continuation.finish(throwing: error)602                }603            }604            continuation.onTermination = { _ in task.cancel() }605        }606    }607608    func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {609        // Some models reject non-streaming calls — aggregate a stream instead.610        if request.model.parameterSupport.requiresStreaming {611            return try await completeViaStream(request, apiKey: apiKey)612        }613        var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)614        var plainRequest = request615        plainRequest.stream = false616        urlReq.httpBody = try buildBody(plainRequest)617        let data = try await StreamingService.postJSON(urlReq, provider: providerID)618        let chunk = try decodeOrThrow(WireChunk.self, from: data)619        guard let choice = chunk.choices?.first, let content = choice.message ?? choice.delta else {620            throw ProviderError.invalidResponse(providerID, detail: "response contained no message")621        }622        var message = Message(623            role: .assistant,624            text: content.content ?? choice.text ?? "",625            reasoning: content.reasoningContent ?? content.reasoning,626            modelID: request.model.id,627            provider: providerID628        )629        if let wireCalls = content.toolCalls, !wireCalls.isEmpty {630            message.toolCalls = wireCalls.enumerated().map { offset, call in631                ToolCall(632                    id: call.id ?? "call_\(call.index ?? offset)_\(UUID().uuidString.prefix(8))",633                    name: call.function?.name ?? "",634                    argumentsJSON: call.function?.arguments ?? "{}",635                    thoughtSignature: call.extraContent?.google.thoughtSignature636                )637            }638        }639        if let citations = Self.citations(from: chunk) {640            message.citations = citations641        }642        if let usage = chunk.usage?.usage {643            message.usage = usage644            message.estimatedCost = request.model.pricing?.cost(645                inputTokens: usage.inputTokens, outputTokens: usage.outputTokens646            )647        }648        return message649    }650651    func listModelIDs(apiKey: String) async throws -> [String] {652        let urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET")653        let data = try await StreamingService.getJSON(urlReq, provider: providerID)654        // Together returns a bare array; everyone else wraps in {"data": […]}.655        // Gemini's compat endpoint prefixes IDs with "models/" — normalize.656        let ids: [String]657        if let list = try? JSONDecoder().decode(WireModelList.self, from: data) {658            ids = list.data.map(\.id)659        } else if let bare = try? JSONDecoder().decode([WireModelEntry].self, from: data) {660            ids = bare.map(\.id)661        } else {662            throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape")663        }664        return ids.map { $0.hasPrefix("models/") ? String($0.dropFirst(7)) : $0 }665    }666667    /// Non-streaming result assembled from the streaming endpoint, for models668    /// that only support `stream: true`.669    private func completeViaStream(_ request: ChatRequest, apiKey: String) async throws -> Message {670        var text = ""671        var reasoning = ""672        var citations: [Citation] = []673        var toolCalls: [ToolCall] = []674        var usage: TokenUsage?675        for try await event in streamChat(request, apiKey: apiKey) {676            switch event {677            case .textDelta(let delta): text += delta678            case .reasoningDelta(let delta): reasoning += delta679            case .citations(let c): citations = c680            case .toolCalls(let calls): toolCalls = calls681            case .toolCallStarted, .toolCallArgumentsDelta: break // covered by .toolCalls682            case .usage(let u): usage = u683            case .finished: break684            }685        }686        var message = Message(687            role: .assistant,688            text: text,689            reasoning: reasoning.isEmpty ? nil : reasoning,690            citations: citations,691            toolCalls: toolCalls.isEmpty ? nil : toolCalls,692            modelID: request.model.id,693            provider: providerID694        )695        if let usage {696            message.usage = usage697            message.estimatedCost = request.model.pricing?.cost(698                inputTokens: usage.inputTokens, outputTokens: usage.outputTokens699            )700        }701        return message702    }703704    // MARK: - Helpers705706    private func decodeOrThrow<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {707        do {708            return try JSONDecoder().decode(type, from: data)709        } catch {710            throw ProviderError.invalidResponse(providerID, detail: "decode failed: \(error.localizedDescription)")711        }712    }713714    /// Perplexity: `citations` is an array of URL strings; `search_results`715    /// adds titles. Merge both into numbered citations.716    private static func citations(from chunk: WireChunk) -> [Citation]? {717        guard let urls = chunk.citations, !urls.isEmpty else { return nil }718        let titles = chunk.searchResults ?? []719        return urls.enumerated().compactMap { index, urlString in720            guard let url = URL(string: urlString) else { return nil }721            let title = index < titles.count ? titles[index].title : nil722            return Citation(index: index + 1, url: url, title: title)723        }724    }725}726