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