SPB Git

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%
29.8 KB · 649 lines swift
Raw Blame History
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