SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
5.7 KB · 147 lines swift
Raw Blame History
1//2//  ProviderProtocol.swift3//  Zyquo Atlas4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation1011/// A provider-agnostic chat request. Clients translate this into their wire format;12/// provider behavior differences never leak above this layer.13struct ChatRequest {14    var model: AIModel15    var systemPrompt: String?16    var messages: [Message]17    var parameters: ChatParameters18    var stream: Bool = true19}2021/// Incremental events surfaced while a response streams.22enum ChatEvent {23    case reasoningDelta(String)24    case textDelta(String)25    case citations([Citation])26    case usage(TokenUsage)27    case finished(reason: String?)28}2930/// One cloud AI provider client.31protocol ProviderClient {32    var providerID: ProviderID { get }3334    /// Streams a chat completion. The stream finishes after `.finished` or throws a `ProviderError`.35    func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error>3637    /// Non-streaming completion (used for title generation and the verify harness).38    func complete(_ request: ChatRequest, apiKey: String) async throws -> Message3940    /// Model IDs currently served by the provider, for dynamic catalog refresh.41    func listModelIDs(apiKey: String) async throws -> [String]42}4344extension ProviderClient {45    /// Key validation: performs the cheapest authenticated call available and46    /// returns the round-trip latency. `fallbackModel` is used for providers47    /// without a /models endpoint (Perplexity) — pass the provider's cheapest48    /// catalog model.49    func testKey(_ apiKey: String, fallbackModel: AIModel?) async throws -> TimeInterval {50        let start = Date()51        if providerID.supportsModelListing {52            _ = try await listModelIDs(apiKey: apiKey)53        } else {54            guard let model = fallbackModel else {55                throw ProviderError.noModelAvailable(providerID)56            }57            var request = ChatRequest(58                model: model,59                systemPrompt: nil,60                messages: [Message(role: .user, text: "Reply with exactly: OK")],61                parameters: ChatParameters(maxTokens: 16),62                stream: false63            )64            request.parameters.temperature = nil65            _ = try await complete(request, apiKey: apiKey)66        }67        return Date().timeIntervalSince(start)68    }69}7071/// Errors mapped to clear, human-readable messages ("Invalid API key for Mistral",72/// "Rate limited — retrying in 20s").73enum ProviderError: LocalizedError {74    case invalidAPIKey(ProviderID)75    case rateLimited(ProviderID, retryAfter: TimeInterval?)76    case serverError(ProviderID, status: Int, message: String?)77    case badRequest(ProviderID, message: String?)78    case networkError(underlying: Error)79    case invalidResponse(ProviderID, detail: String)80    case missingAPIKey(ProviderID)81    case noModelAvailable(ProviderID)82    case cancelled8384    var errorDescription: String? {85        switch self {86        case .invalidAPIKey(let p):87            return "Invalid API key for \(p.displayName)."88        case .rateLimited(let p, let retryAfter):89            if let s = retryAfter {90                return "\(p.displayName) rate limited — retry in \(Int(s.rounded()))s."91            }92            return "\(p.displayName) rate limited — please retry shortly."93        case .serverError(let p, let status, let message):94            return "\(p.displayName) server error (\(status))\(message.map { ": \($0)" } ?? "")."95        case .badRequest(let p, let message):96            return "\(p.displayName) rejected the request\(message.map { ": \($0)" } ?? "")."97        case .networkError(let underlying):98            return "Network error: \(underlying.localizedDescription)"99        case .invalidResponse(let p, let detail):100            return "Unexpected response from \(p.displayName): \(detail)"101        case .missingAPIKey(let p):102            return "No API key configured for \(p.displayName). Add one in Settings → Providers & Keys."103        case .noModelAvailable(let p):104            return "No model available for \(p.displayName)."105        case .cancelled:106            return "Generation stopped."107        }108    }109110    /// Maps an HTTP status + provider error body to a typed error.111    static func from(status: Int, body: Data, provider: ProviderID) -> ProviderError {112        let message = Self.extractMessage(from: body)113        switch status {114        case 401, 403:115            return .invalidAPIKey(provider)116        case 429:117            return .rateLimited(provider, retryAfter: nil)118        case 400, 404, 422:119            return .badRequest(provider, message: message)120        default:121            return .serverError(provider, status: status, message: message)122        }123    }124125    /// Providers wrap errors differently ({"error":{"message":…}}, {"message":…},126    /// {"error":"…"}, Gemini arrays…). Try the common shapes.127    private static func extractMessage(from body: Data) -> String? {128        guard let obj = try? JSONSerialization.jsonObject(with: body) else {129            return String(data: body.prefix(300), encoding: .utf8)130        }131        if let dict = obj as? [String: Any] {132            if let err = dict["error"] as? [String: Any], let msg = err["message"] as? String {133                return msg134            }135            if let msg = dict["error"] as? String { return msg }136            if let msg = dict["message"] as? String { return msg }137            if let msg = dict["detail"] as? String { return msg }138        }139        if let arr = obj as? [[String: Any]],140           let err = arr.first?["error"] as? [String: Any],141           let msg = err["message"] as? String {142            return msg143        }144        return String(data: body.prefix(300), encoding: .utf8)145    }146}147