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