// // ProviderProtocol.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation /// A provider-agnostic chat request. Clients translate this into their wire format; /// provider behavior differences never leak above this layer. struct ChatRequest { var model: AIModel var systemPrompt: String? var messages: [Message] var parameters: ChatParameters var stream: Bool = true } /// Incremental events surfaced while a response streams. enum ChatEvent { case reasoningDelta(String) case textDelta(String) case citations([Citation]) case usage(TokenUsage) case finished(reason: String?) } /// One cloud AI provider client. protocol ProviderClient { var providerID: ProviderID { get } /// Streams a chat completion. The stream finishes after `.finished` or throws a `ProviderError`. func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream /// Non-streaming completion (used for title generation and the verify harness). func complete(_ request: ChatRequest, apiKey: String) async throws -> Message /// Model IDs currently served by the provider, for dynamic catalog refresh. func listModelIDs(apiKey: String) async throws -> [String] } extension ProviderClient { /// Key validation: performs the cheapest authenticated call available and /// returns the round-trip latency. `fallbackModel` is used for providers /// without a /models endpoint (Perplexity) — pass the provider's cheapest /// catalog model. func testKey(_ apiKey: String, fallbackModel: AIModel?) async throws -> TimeInterval { let start = Date() if providerID.supportsModelListing { _ = try await listModelIDs(apiKey: apiKey) } else { guard let model = fallbackModel else { throw ProviderError.noModelAvailable(providerID) } var request = ChatRequest( model: model, systemPrompt: nil, messages: [Message(role: .user, text: "Reply with exactly: OK")], parameters: ChatParameters(maxTokens: 16), stream: false ) request.parameters.temperature = nil _ = try await complete(request, apiKey: apiKey) } return Date().timeIntervalSince(start) } } /// Errors mapped to clear, human-readable messages ("Invalid API key for Mistral", /// "Rate limited — retrying in 20s"). enum ProviderError: LocalizedError { case invalidAPIKey(ProviderID) case rateLimited(ProviderID, retryAfter: TimeInterval?) case serverError(ProviderID, status: Int, message: String?) case badRequest(ProviderID, message: String?) case networkError(underlying: Error) case invalidResponse(ProviderID, detail: String) case missingAPIKey(ProviderID) case noModelAvailable(ProviderID) case cancelled var errorDescription: String? { switch self { case .invalidAPIKey(let p): return "Invalid API key for \(p.displayName)." case .rateLimited(let p, let retryAfter): if let s = retryAfter { return "\(p.displayName) rate limited — retry in \(Int(s.rounded()))s." } return "\(p.displayName) rate limited — please retry shortly." case .serverError(let p, let status, let message): return "\(p.displayName) server error (\(status))\(message.map { ": \($0)" } ?? "")." case .badRequest(let p, let message): return "\(p.displayName) rejected the request\(message.map { ": \($0)" } ?? "")." case .networkError(let underlying): return "Network error: \(underlying.localizedDescription)" case .invalidResponse(let p, let detail): return "Unexpected response from \(p.displayName): \(detail)" case .missingAPIKey(let p): return "No API key configured for \(p.displayName). Add one in Settings → Providers & Keys." case .noModelAvailable(let p): return "No model available for \(p.displayName)." case .cancelled: return "Generation stopped." } } /// Maps an HTTP status + provider error body to a typed error. static func from(status: Int, body: Data, provider: ProviderID) -> ProviderError { let message = Self.extractMessage(from: body) switch status { case 401, 403: return .invalidAPIKey(provider) case 429: return .rateLimited(provider, retryAfter: nil) case 400, 404, 422: return .badRequest(provider, message: message) default: return .serverError(provider, status: status, message: message) } } /// Providers wrap errors differently ({"error":{"message":…}}, {"message":…}, /// {"error":"…"}, Gemini arrays…). Try the common shapes. private static func extractMessage(from body: Data) -> String? { guard let obj = try? JSONSerialization.jsonObject(with: body) else { return String(data: body.prefix(300), encoding: .utf8) } if let dict = obj as? [String: Any] { if let err = dict["error"] as? [String: Any], let msg = err["message"] as? String { return msg } if let msg = dict["error"] as? String { return msg } if let msg = dict["message"] as? String { return msg } if let msg = dict["detail"] as? String { return msg } } if let arr = obj as? [[String: Any]], let err = arr.first?["error"] as? [String: Any], let msg = err["message"] as? String { return msg } return String(data: body.prefix(300), encoding: .utf8) } }