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%
6.2 KB · 138 lines swift
Raw Blame History
1//2//  UpstreamCall.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Executes one request against one upstream provider: endpoint + auth9//  construction per wire format, JSON POST for non-streaming (retries live10//  in StreamingService.postJSON), raw SSE event stream for streaming.11//  Cancellation of the calling task cancels the upstream transfer.12//1314import Foundation1516struct UpstreamCall {17    let model: AIModel18    let apiKey: String1920    private var provider: ProviderID { model.provider }2122    enum CallError: Error {23        case noEndpoint(ProviderID)24    }2526    // MARK: - Endpoint + auth2728    private func urlRequest(streaming: Bool, body: [String: Any]) throws -> URLRequest {29        let url: URL30        switch provider.wireFormat {31        case .anthropicMessages:32            guard let base = model.customBaseURL ?? provider.defaultBaseURL else {33                throw CallError.noEndpoint(provider)34            }35            url = base.appendingPathComponent("messages")36        case .openAIChatCompletions where provider == .gemini && Self.usesNativeGemini:37            // Native generateContent (D10): Cloud's catalog stores the38            // OpenAI-compat base (…/v1beta/openai); derive the native root.39            let root = "https://generativelanguage.googleapis.com/v1beta"40            let verb = streaming ? "streamGenerateContent?alt=sse" : "generateContent"41            guard let native = URL(string: "\(root)/models/\(model.id):\(verb)") else {42                throw CallError.noEndpoint(provider)43            }44            url = native45        case .openAIChatCompletions:46            guard let base = model.customBaseURL ?? provider.defaultBaseURL else {47                throw CallError.noEndpoint(provider)48            }49            url = base.appendingPathComponent("chat/completions")50        }5152        var request = URLRequest(url: url)53        request.httpMethod = "POST"54        request.setValue("application/json", forHTTPHeaderField: "Content-Type")55        switch provider {56        case .anthropic:57            request.setValue(apiKey, forHTTPHeaderField: "x-api-key")58            request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")59        case .gemini:60            request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")61        default:62            request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")63        }64        if streaming {65            request.setValue("text/event-stream", forHTTPHeaderField: "Accept")66        }67        request.httpBody = try JSONSerialization.data(withJSONObject: body)68        return request69    }7071    /// Gemini goes through the native generateContent translation.72    static let usesNativeGemini = true7374    /// Whether this call's upstream speaks the native Gemini API.75    var isNativeGemini: Bool { provider == .gemini && Self.usesNativeGemini }7677    // MARK: - Execution7879    /// Non-streaming: returns the upstream JSON object.80    /// StreamingService.postJSON already retries 429/5xx with backoff.81    func complete(body: [String: Any]) async throws -> [String: Any] {82        let request = try urlRequest(streaming: false, body: body)83        let data = try await StreamingService.postJSON(request, provider: provider)84        guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {85            throw ProviderError.invalidResponse(provider, detail: "response is not a JSON object")86        }87        return json88    }8990    /// Streaming: raw upstream SSE events. Errors before the first event are91    /// retryable by the caller (never after the first forwarded byte).92    func stream(body: [String: Any]) throws -> AsyncThrowingStream<SSEEvent, Error> {93        let request = try urlRequest(streaming: true, body: body)94        return StreamingService.sseEvents(for: request, provider: provider)95    }96}9798// MARK: - ProviderError → OpenAI wire error99100extension ProviderError {101    /// Maps upstream failures to (HTTP status, OpenAI error type/code) per102    /// decision D7 — clear messages, no provider payload shapes, no keys.103    var openAIWire: (status: Int, type: String, code: String?, message: String) {104        switch self {105        case .invalidAPIKey(let provider):106            return (401, "authentication_error", "invalid_provider_key",107                    "The stored \(provider.displayName) API key was rejected upstream. Update it in Zyquo Router → Keys.")108        case .missingAPIKey(let provider):109            return (401, "authentication_error", "missing_provider_key",110                    "No \(provider.displayName) API key is configured. Add one in Zyquo Router → Keys.")111        case .rateLimited(let provider, let retryAfter):112            let hint = retryAfter.map { " Retry in \(Int($0.rounded()))s." } ?? ""113            return (429, "rate_limit_error", "upstream_rate_limited",114                    "\(provider.displayName) rate-limited the request.\(hint)")115        case .badRequest(let provider, let message):116            return (400, "invalid_request_error", nil,117                    "\(provider.displayName) rejected the request\(message.map { ": \($0)" } ?? ".")")118        case .serverError(let provider, let status, _):119            return (502, "api_error", "upstream_error",120                    "\(provider.displayName) upstream error (HTTP \(status)).")121        case .networkError(let underlying):122            if (underlying as? URLError)?.code == .timedOut {123                return (504, "api_error", "upstream_timeout", "The upstream request timed out.")124            }125            return (502, "api_error", "upstream_unreachable",126                    "Could not reach the upstream provider: \(underlying.localizedDescription)")127        case .invalidResponse(let provider, let detail):128            return (502, "api_error", "upstream_invalid_response",129                    "Unexpected response from \(provider.displayName): \(detail)")130        case .noModelAvailable(let provider):131            return (404, "invalid_request_error", "model_not_found",132                    "No model available for \(provider.displayName).")133        case .cancelled:134            return (499, "api_error", "client_disconnected", "The client disconnected.")135        }136    }137}138