spb/zyquo-atlas Public License
The AI-native macOS web browser — every surface, intelligent.
Swift 75.2%
JavaScript 22%
Shell 2%
Makefile 0.9%
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