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// ChatCompletionsRoute.swift3// Zyquo Router4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// POST /v1/chat/completions — the core of the router. Parses the OpenAI9// request, resolves the model (aliases, fallback chains), checks10// capabilities, calls the upstream through the right translation path,11// and returns a spec-exact response or byte-exact chunk stream. Upstream12// failures become OpenAI-shaped errors (D7); transient ones retry before13// the first forwarded byte (D8); usage is metered, estimated-and-flagged14// when the upstream reports none (D9).15//1617import Foundation18import NIOHTTP11920struct ChatCompletionsRoute {21 let router: RequestRouter22 let providerKey: @Sendable (ProviderID) -> String?23 let usageMeter: UsageMeter24 let requestLog: RequestLogStore25 let retryPolicy: RetryPolicy2627 /// Cap on stored body previews in the request log (inspector display).28 private static let previewLimit = 20_0002930 static func prettyJSON(_ data: Data) -> String {31 guard let object = try? JSONSerialization.jsonObject(with: data),32 let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) else {33 return String(decoding: data.prefix(previewLimit), as: UTF8.self)34 }35 return String(decoding: pretty.prefix(previewLimit), as: UTF8.self)36 }3738 func handle(_ request: RouteRequest, localKey: APIKeyRecord?) async -> RouteResult {39 // Parse.40 let chat: ChatCompletionRequest41 do {42 chat = try ChatCompletionRequest(body: request.body)43 } catch let error as ChatCompletionRequest.ParseError {44 return OpenAIError.response(45 status: .badRequest,46 message: error.localizedDescription,47 type: "invalid_request_error",48 param: error.param49 )50 } catch {51 return OpenAIError.response(52 status: .badRequest,53 message: "Malformed JSON body.",54 type: "invalid_request_error"55 )56 }5758 // Resolve the primary model + any configured fallback chain.59 var candidates: [RequestRouter.Resolution] = []60 do {61 let primary = try router.resolve(chat.model)62 candidates.append(primary)63 for fallbackID in router.fallbackChains[primary.namespacedID] ?? [] {64 if let fallback = try? router.resolve(fallbackID) {65 candidates.append(fallback)66 }67 }68 } catch let error as RequestRouter.RoutingError {69 return Routes.routingErrorResponse(error)70 } catch {71 return OpenAIError.response(status: .internalServerError, message: "Internal error.", type: "server_error")72 }7374 // Per-key model allow-list.75 if let allowed = localKey?.allowedModels,76 let primary = candidates.first, !allowed.contains(primary.namespacedID) {77 return OpenAIError.response(78 status: .forbidden,79 message: "This API key is not allowed to use `\(candidates[0].namespacedID)`.",80 type: "permission_error",81 code: "model_not_allowed"82 )83 }8485 // Try candidates in order; report the actually-used model honestly.86 let requestPreview = Self.prettyJSON(request.body)87 var lastFailure: ProviderError = .noModelAvailable(candidates[0].model.provider)88 for (index, resolution) in candidates.enumerated() {89 let isLastCandidate = index == candidates.count - 190 switch await attempt(chat: chat, resolution: resolution, localKey: localKey, requestPreview: requestPreview) {91 case .success(let result):92 return result93 case .failure(let error):94 lastFailure = error95 if isLastCandidate || !shouldFallback(on: error) {96 return errorResponse(for: error)97 }98 }99 }100 return errorResponse(for: lastFailure)101 }102103 // MARK: - One candidate attempt104105 private enum AttemptOutcome {106 case success(RouteResult)107 case failure(ProviderError)108 }109110 private func attempt(111 chat: ChatCompletionRequest,112 resolution: RequestRouter.Resolution,113 localKey: APIKeyRecord?,114 requestPreview: String115 ) async -> AttemptOutcome {116 let model = resolution.model117118 // Capability gates — clear OpenAI errors instead of upstream 400s.119 if chat.hasTools, !model.capabilities.tools {120 return .failure(.badRequest(model.provider, message: "`\(resolution.namespacedID)` does not support tools/function calling"))121 }122 if chat.hasImageContent, !model.capabilities.vision {123 return .failure(.badRequest(model.provider, message: "`\(resolution.namespacedID)` does not accept image input"))124 }125 guard let apiKey = providerKey(model.provider) else {126 return .failure(.missingAPIKey(model.provider))127 }128 let call = UpstreamCall(model: model, apiKey: apiKey)129 if call.isNativeGemini, GeminiTranslator.hasRemoteImageURL(chat) {130 return .failure(.badRequest(model.provider, message: "Gemini requires images as base64 data URIs — remote image URLs are not fetched by the router"))131 }132133 // A model that rejects non-streaming calls is transparently streamed134 // and aggregated when the client asked for a buffered response.135 let clientWantsStream = chat.stream136 let mustStreamUpstream = model.parameterSupport.requiresStreaming137 || CompatAdjuster.requiresStreamingOverride(model)138139 if clientWantsStream {140 return await streamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey, requestPreview: requestPreview)141 }142 if mustStreamUpstream {143 return await aggregatedStreamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey, requestPreview: requestPreview)144 }145 return await bufferedAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey, requestPreview: requestPreview)146 }147148 private func shouldFallback(on error: ProviderError) -> Bool {149 switch error {150 case .rateLimited, .serverError, .networkError, .invalidResponse, .missingAPIKey, .invalidAPIKey:151 return true152 case .badRequest, .noModelAvailable, .cancelled:153 return false154 }155 }156157 private func errorResponse(for error: ProviderError) -> RouteResult {158 let wire = error.openAIWire159 var extraHeaders: [(String, String)] = []160 if case .rateLimited(_, let retryAfter) = error, let retryAfter {161 extraHeaders.append(("Retry-After", String(Int(retryAfter.rounded()))))162 }163 return OpenAIError.response(164 status: HTTPResponseStatus(statusCode: wire.status),165 message: wire.message,166 type: wire.type,167 code: wire.code,168 extraHeaders: extraHeaders169 )170 }171172 // MARK: - Buffered (non-streaming)173174 private func bufferedAttempt(175 chat: ChatCompletionRequest,176 resolution: RequestRouter.Resolution,177 call: UpstreamCall,178 localKey: APIKeyRecord?,179 requestPreview: String180 ) async -> AttemptOutcome {181 let started = Date()182 let emitter = ChunkEmitter(model: resolution.namespacedID)183 do {184 let response: [String: Any]185 switch upstreamKind(call) {186 case .anthropic:187 let body = AnthropicTranslator.buildRequest(chat, model: resolution.model)188 response = AnthropicTranslator.translateResponse(try await call.complete(body: body), emitter: emitter)189 case .gemini:190 let body = GeminiTranslator.buildRequest(chat, model: resolution.model)191 let upstream = try await call.complete(body: body)192 if let blocked = GeminiTranslator.blockReason(upstream) {193 return .failure(.badRequest(.gemini, message: "Gemini blocked the prompt (reason: \(blocked))"))194 }195 response = GeminiTranslator.translateResponse(upstream, emitter: emitter)196 case .compat:197 let body = CompatAdjuster.adjustRequest(chat.raw, model: resolution.model, stream: false)198 var normalized = CompatAdjuster.normalizeResponse(199 try await call.complete(body: body),200 namespacedModel: resolution.namespacedID,201 provider: resolution.model.provider202 )203 if normalized["usage"] == nil {204 normalized["usage"] = estimatedUsage(chat: chat, outputText: Self.responseText(normalized))205 }206 response = normalized207 }208209 let body = ChunkEmitter.serialize(response)210 await meter(211 response: response, resolution: resolution, localKey: localKey,212 started: started, streamed: false, status: 200,213 requestBody: requestPreview, responseBody: Self.prettyJSON(body)214 )215 return .success(.complete(216 status: .ok,217 headers: [("Content-Type", "application/json")],218 body: body219 ))220 } catch let error as ProviderError {221 await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error, requestBody: requestPreview)222 return .failure(error)223 } catch {224 let wrapped = ProviderError.networkError(underlying: error)225 await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: wrapped, requestBody: requestPreview)226 return .failure(wrapped)227 }228 }229230 // MARK: - Streaming231232 private func streamingAttempt(233 chat: ChatCompletionRequest,234 resolution: RequestRouter.Resolution,235 call: UpstreamCall,236 localKey: APIKeyRecord?,237 requestPreview: String238 ) async -> AttemptOutcome {239 // Pre-flight retries: transient failures before ANY byte reaches the240 // client are retried/fallback-able. Open the upstream stream and pull241 // its first event before committing to the client response.242 let started = Date()243 var attempt = 1244 while true {245 do {246 let (events, firstEvent) = try await openUpstreamStream(chat: chat, call: call, resolution: resolution)247 return .success(streamResult(248 chat: chat, resolution: resolution, call: call, localKey: localKey,249 events: events, firstEvent: firstEvent,250 started: started, ttfb: Date().timeIntervalSince(started),251 requestPreview: requestPreview252 ))253 } catch let error as ProviderError {254 let retryable: Bool255 switch error {256 case .rateLimited: retryable = true257 case .serverError(_, let status, _): retryable = retryPolicy.shouldRetry(status: status, attempt: attempt)258 case .networkError: retryable = attempt < retryPolicy.maxAttempts259 default: retryable = false260 }261 var retryAfter: TimeInterval?262 if case .rateLimited(_, let after) = error { retryAfter = after }263 guard retryable, attempt < retryPolicy.maxAttempts else { return .failure(error) }264 try? await Task.sleep(nanoseconds: UInt64(retryPolicy.delay(attempt: attempt, retryAfter: retryAfter) * 1_000_000_000))265 attempt += 1266 } catch {267 return .failure(.networkError(underlying: error))268 }269 }270 }271272 /// Opens the upstream stream and awaits its first event so that upstream273 /// HTTP errors surface here (retryable) instead of mid-client-stream.274 private func openUpstreamStream(275 chat: ChatCompletionRequest,276 call: UpstreamCall,277 resolution: RequestRouter.Resolution278 ) async throws -> (AsyncThrowingStream<SSEEvent, Error>.AsyncIterator, SSEEvent?) {279 let body: [String: Any]280 switch upstreamKind(call) {281 case .anthropic:282 body = AnthropicTranslator.buildRequest(chat, model: resolution.model)283 case .gemini:284 body = GeminiTranslator.buildRequest(chat, model: resolution.model)285 case .compat:286 body = CompatAdjuster.adjustRequest(chat.raw, model: resolution.model, stream: true)287 }288 var iterator = try call.stream(body: body).makeAsyncIterator()289 let first = try await iterator.next()290 return (iterator, first)291 }292293 private func streamResult(294 chat: ChatCompletionRequest,295 resolution: RequestRouter.Resolution,296 call: UpstreamCall,297 localKey: APIKeyRecord?,298 events: AsyncThrowingStream<SSEEvent, Error>.AsyncIterator,299 firstEvent: SSEEvent?,300 started: Date,301 ttfb: TimeInterval,302 requestPreview: String303 ) -> RouteResult {304 return .stream(status: .ok, headers: []) { writer in305 await self.usageMeter.streamBegan()306 defer { Task { await self.usageMeter.streamEnded() } }307 var iterator = events308 var next = firstEvent309 var usage: [String: Any]?310 var finishReason: String?311 var preview = ""312313 func iterate(_ handle: (SSEEvent) async throws -> Void) async throws {314 while let event = next {315 try await handle(event)316 next = try await iterator.next()317 }318 }319320 do {321 switch self.upstreamKind(call) {322 case .anthropic:323 var machine = AnthropicTranslator.StreamMachine(324 emitter: ChunkEmitter(model: resolution.namespacedID),325 includeUsage: chat.includeUsage326 )327 try await iterate { event in328 let (payloads, _) = machine.consume(event)329 for payload in payloads { try await writer.send(raw: payload) }330 }331 usage = UsageBuilder.build(332 promptTokens: machine.promptTokens,333 completionTokens: machine.completionTokens,334 cachedTokens: machine.cachedTokens > 0 ? machine.cachedTokens : nil335 )336 finishReason = machine.finishReasonSent337 preview = machine.textPreview338339 case .gemini:340 var machine = GeminiTranslator.StreamMachine(341 emitter: ChunkEmitter(model: resolution.namespacedID),342 includeUsage: chat.includeUsage343 )344 try await iterate { event in345 for payload in machine.consume(event) { try await writer.send(raw: payload) }346 }347 for payload in machine.finalPayloads() { try await writer.send(raw: payload) }348 usage = machine.lastUsage.map(GeminiTranslator.normalizedUsage)349 finishReason = "stop"350 preview = machine.textPreview351352 case .compat:353 // Spec-discipline guards for deviant upstreams: synthesize354 // the role delta if the first chunk lacks it, and turn a355 // missing finish_reason (Perplexity puts it only in its356 // non-spec `.done` summary event) into a proper finish357 // chunk before usage/[DONE].358 let emitter = ChunkEmitter(model: resolution.namespacedID)359 var usageChunkForwarded = false360 var roleForwarded = false361 var doneEventFinish: String?362 try await iterate { event in363 if event.data == "[DONE]" { return }364 guard let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any] else { return }365 guard let chunk = CompatAdjuster.normalizeChunk(366 json,367 namespacedModel: resolution.namespacedID,368 provider: resolution.model.provider,369 clientWantsUsage: chat.includeUsage370 ) else {371 // Swallowed event (usage-only chunk, Perplexity372 // `.done` summary): capture its usage + finish.373 if let chunkUsage = json["usage"] as? [String: Any] { usage = chunkUsage }374 if let finish = ((json["choices"] as? [[String: Any]])?.first?["finish_reason"] as? String) {375 doneEventFinish = CompatAdjuster.normalizeFinishReason(finish)376 }377 return378 }379 if let chunkUsage = chunk["usage"] as? [String: Any] {380 usage = chunkUsage381 if (chunk["choices"] as? [Any])?.isEmpty == true { usageChunkForwarded = true }382 }383 if let choices = chunk["choices"] as? [[String: Any]] {384 for choice in choices {385 if let finish = choice["finish_reason"] as? String { finishReason = finish }386 if let delta = choice["delta"] as? [String: Any] {387 if !roleForwarded, delta["role"] == nil, !(chunk["choices"] as? [Any] ?? []).isEmpty {388 try await writer.send(raw: emitter.roleChunk())389 }390 roleForwarded = true391 if let content = delta["content"] as? String,392 preview.count < Self.previewLimit {393 preview += content394 }395 }396 }397 }398 try await writer.send(raw: ChunkEmitter.serialize(chunk))399 }400 if finishReason == nil {401 // Even an empty stream must open with the role delta.402 if !roleForwarded {403 roleForwarded = true404 try await writer.send(raw: emitter.roleChunk())405 }406 let reason = doneEventFinish ?? "stop"407 finishReason = reason408 try await writer.send(raw: emitter.finishChunk(reason: reason))409 }410 // Client asked for usage but no usage chunk was forwarded411 // (upstream sent none, or only in a swallowed event).412 if chat.includeUsage, !usageChunkForwarded {413 let payload = usage ?? self.estimatedUsage(chat: chat, outputText: preview)414 usage = payload415 try await writer.send(raw: emitter.usageChunk(payload))416 }417 }418 try await writer.sendDone()419 _ = finishReason420 await self.meter(421 usageDict: usage, resolution: resolution,422 localKey: localKey, started: started, streamed: true, status: 200,423 ttfb: ttfb, requestBody: requestPreview, responseBody: preview424 )425 } catch let error as ProviderError {426 // Mid-stream failure: never retry (bytes were forwarded).427 // Emit a LiteLLM-style error frame, then terminate.428 await self.meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: true, error: error, requestBody: requestPreview)429 let wire = error.openAIWire430 let frame = OpenAIError(error: .init(message: wire.message, type: wire.type, param: nil, code: wire.code))431 try? await writer.send(raw: (try? JSONEncoder().encode(frame)) ?? Data())432 try? await writer.sendDone()433 }434 }435 }436437 /// Client asked non-streaming but the model only streams: aggregate.438 private func aggregatedStreamingAttempt(439 chat: ChatCompletionRequest,440 resolution: RequestRouter.Resolution,441 call: UpstreamCall,442 localKey: APIKeyRecord?,443 requestPreview: String444 ) async -> AttemptOutcome {445 let started = Date()446 do {447 var content = ""448 var reasoning = ""449 var toolCalls: [Int: [String: Any]] = [:]450 var finishReason = "stop"451 var usage: [String: Any]?452453 // Produce normalized OpenAI chunk dicts from whichever upstream454 // wire this model speaks, then fold them into one completion.455 let kind = upstreamKind(call)456 let emitterForMachines = ChunkEmitter(model: resolution.namespacedID)457 var anthropicMachine = AnthropicTranslator.StreamMachine(emitter: emitterForMachines, includeUsage: true)458 var geminiMachine = GeminiTranslator.StreamMachine(emitter: emitterForMachines, includeUsage: true)459460 let body: [String: Any]461 switch kind {462 case .anthropic:463 body = AnthropicTranslator.buildRequest(chat, model: resolution.model)464 case .gemini:465 body = GeminiTranslator.buildRequest(chat, model: resolution.model)466 case .compat:467 body = CompatAdjuster.adjustRequest(chat.raw, model: resolution.model, stream: true)468 }469470 var chunks: [[String: Any]] = []471 for try await event in try call.stream(body: body) {472 if event.data == "[DONE]" { break }473 switch kind {474 case .anthropic:475 chunks.append(contentsOf: anthropicMachine.consume(event).payloads.compactMap {476 (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any]477 })478 case .gemini:479 chunks.append(contentsOf: geminiMachine.consume(event).compactMap {480 (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any]481 })482 case .compat:483 if let json = (try? JSONSerialization.jsonObject(with: Data(event.data.utf8))) as? [String: Any],484 let chunk = CompatAdjuster.normalizeChunk(485 json, namespacedModel: resolution.namespacedID,486 provider: resolution.model.provider, clientWantsUsage: true487 ) {488 chunks.append(chunk)489 }490 }491 }492 if kind == .gemini {493 chunks.append(contentsOf: geminiMachine.finalPayloads().compactMap {494 (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any]495 })496 }497498 for chunk in chunks {499 if let chunkUsage = chunk["usage"] as? [String: Any] { usage = chunkUsage }500 for choice in chunk["choices"] as? [[String: Any]] ?? [] {501 if let finish = choice["finish_reason"] as? String { finishReason = finish }502 guard let delta = choice["delta"] as? [String: Any] else { continue }503 content += delta["content"] as? String ?? ""504 reasoning += delta["reasoning_content"] as? String ?? ""505 for call in delta["tool_calls"] as? [[String: Any]] ?? [] {506 let index = call["index"] as? Int ?? 0507 var existing = toolCalls[index] ?? ["type": "function", "function": ["name": "", "arguments": ""]]508 if let id = call["id"] as? String { existing["id"] = id }509 if let function = call["function"] as? [String: Any] {510 var merged = existing["function"] as? [String: Any] ?? [:]511 if let name = function["name"] as? String, !name.isEmpty { merged["name"] = name }512 merged["arguments"] = (merged["arguments"] as? String ?? "") + (function["arguments"] as? String ?? "")513 existing["function"] = merged514 }515 toolCalls[index] = existing516 }517 }518 }519520 var message: [String: Any] = ["role": "assistant", "content": content]521 if !reasoning.isEmpty { message["reasoning_content"] = reasoning }522 if !toolCalls.isEmpty {523 message["tool_calls"] = toolCalls.sorted { $0.key < $1.key }.map(\.value)524 if finishReason == "stop" { finishReason = "tool_calls" }525 }526 let emitter = ChunkEmitter(model: resolution.namespacedID)527 let response = emitter.completion(528 message: message,529 finishReason: finishReason,530 usage: usage ?? estimatedUsage(chat: chat, outputText: content + reasoning)531 )532 let responseData = ChunkEmitter.serialize(response)533 await meter(534 response: response, resolution: resolution, localKey: localKey,535 started: started, streamed: false, status: 200,536 requestBody: requestPreview, responseBody: Self.prettyJSON(responseData)537 )538 return .success(.complete(status: .ok, headers: [("Content-Type", "application/json")], body: responseData))539 } catch let error as ProviderError {540 await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error, requestBody: requestPreview)541 return .failure(error)542 } catch {543 return .failure(.networkError(underlying: error))544 }545 }546547 // MARK: - Shared helpers548549 private enum UpstreamKind {550 case anthropic, gemini, compat551 }552553 private func upstreamKind(_ call: UpstreamCall) -> UpstreamKind {554 if call.model.provider == .anthropic { return .anthropic }555 if call.isNativeGemini { return .gemini }556 return .compat557 }558559 private func estimatedUsage(chat: ChatCompletionRequest, outputText: String) -> [String: Any] {560 let promptText = chat.messages.map(\.flattenedText).joined(separator: "\n")561 return UsageBuilder.build(562 promptTokens: UsageBuilder.estimateTokens(promptText),563 completionTokens: UsageBuilder.estimateTokens(outputText),564 estimated: true565 )566 }567568 private static func responseText(_ response: [String: Any]) -> String {569 ((response["choices"] as? [[String: Any]])?.first?["message"] as? [String: Any])?["content"] as? String ?? ""570 }571572 private func meter(573 response: [String: Any]? = nil,574 usageDict: [String: Any]? = nil,575 resolution: RequestRouter.Resolution,576 localKey: APIKeyRecord?,577 started: Date,578 streamed: Bool,579 status: Int,580 ttfb: TimeInterval? = nil,581 requestBody: String = "",582 responseBody: String = ""583 ) async {584 let usage = usageDict ?? response?["usage"] as? [String: Any] ?? [:]585 let prompt = usage["prompt_tokens"] as? Int ?? 0586 let completion = usage["completion_tokens"] as? Int ?? 0587 let estimated = (usage["x_zyquo"] as? [String: Any])?["usage_estimated"] as? Bool ?? false588 let reasoningTokens = (usage["completion_tokens_details"] as? [String: Any])?["reasoning_tokens"] as? Int589 let tokens = TokenUsage(inputTokens: prompt, outputTokens: completion, reasoningTokens: reasoningTokens)590 let cost = resolution.model.pricing?.cost(inputTokens: prompt, outputTokens: completion)591 let latency = Date().timeIntervalSince(started)592 await usageMeter.record(UsageRecord(593 namespacedModelID: resolution.namespacedID,594 provider: resolution.model.provider,595 localKeyName: localKey?.name,596 usage: tokens,597 usageEstimated: estimated,598 estimatedCost: cost,599 latency: latency,600 streamed: streamed,601 status: status602 ))603 await requestLog.append(RequestLogEntry(604 namespacedModelID: resolution.namespacedID,605 provider: resolution.model.provider,606 status: status,607 streamed: streamed,608 latency: latency,609 upstreamTTFB: ttfb,610 usage: tokens,611 usageEstimated: estimated,612 estimatedCost: cost,613 localKeyName: localKey?.name,614 requestBody: requestBody,615 responseBody: responseBody616 ))617 }618619 private func meterFailure(620 resolution: RequestRouter.Resolution,621 localKey: APIKeyRecord?,622 started: Date,623 streamed: Bool,624 error: ProviderError,625 requestBody: String = ""626 ) async {627 let latency = Date().timeIntervalSince(started)628 let status = error.openAIWire.status629 await usageMeter.record(UsageRecord(630 namespacedModelID: resolution.namespacedID,631 provider: resolution.model.provider,632 localKeyName: localKey?.name,633 latency: latency,634 streamed: streamed,635 status: status636 ))637 await requestLog.append(RequestLogEntry(638 namespacedModelID: resolution.namespacedID,639 provider: resolution.model.provider,640 status: status,641 streamed: streamed,642 latency: latency,643 localKeyName: localKey?.name,644 errorMessage: error.openAIWire.message,645 requestBody: requestBody646 ))647 }648}649