spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// ProviderProtocol.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Ported from Zyquo Cloud and extended with the normalized tool-calling9// interface (docs/PROVIDER-REUSE.md §5.3): ChatRequest carries ToolSpecs and10// a ToolChoice; ChatEvent adds live tool-call streaming cases and a11// normalized StopReason on `.finished`. Per-provider wire translation stays12// inside the clients — the agent loop sees only this surface.13//1415import Foundation1617/// A provider-agnostic chat request. Clients translate this into their wire format;18/// provider behavior differences never leak above this layer.19struct ChatRequest {20 var model: AIModel21 var systemPrompt: String?22 var messages: [Message]23 var parameters: ChatParameters24 var stream: Bool = true25 /// Tools offered to the model (empty = plain chat, wire-identical to Cloud).26 var tools: [ToolSpec] = []27 /// How the model may use the offered tools.28 var toolChoice: ToolChoice = .auto29}3031/// Incremental events surfaced while a response streams.32enum ChatEvent {33 case reasoningDelta(String)34 case textDelta(String)35 case citations([Citation])36 /// A tool-use block/fragment opened: show the live chip in the UI.37 case toolCallStarted(index: Int, id: String, name: String)38 /// Streamed tool-call argument JSON fragments for the call at `index`.39 case toolCallArgumentsDelta(index: Int, delta: String)40 /// The finalized, accumulated set of tool calls for this turn (emitted41 /// once, before `.finished`, whenever the model called tools).42 case toolCalls([ToolCall])43 case usage(TokenUsage)44 /// `reason` is the provider's raw finish/stop string; `stop` is the45 /// normalized value the agent loop branches on.46 case finished(reason: String?, stop: StopReason)47}4849/// One cloud AI provider client.50protocol ProviderClient {51 var providerID: ProviderID { get }5253 /// Streams a chat completion. The stream finishes after `.finished` or throws a `ProviderError`.54 func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error>5556 /// Non-streaming completion (used for title generation and the verify harness).57 func complete(_ request: ChatRequest, apiKey: String) async throws -> Message5859 /// Model IDs currently served by the provider, for dynamic catalog refresh.60 func listModelIDs(apiKey: String) async throws -> [String]61}6263extension ProviderClient {64 /// Key validation: performs the cheapest authenticated call available and65 /// returns the round-trip latency. `fallbackModel` is used for providers66 /// without a /models endpoint (Perplexity) — pass the provider's cheapest67 /// catalog model.68 func testKey(_ apiKey: String, fallbackModel: AIModel?) async throws -> TimeInterval {69 let start = Date()70 if providerID.supportsModelListing {71 _ = try await listModelIDs(apiKey: apiKey)72 } else {73 guard let model = fallbackModel else {74 throw ProviderError.noModelAvailable(providerID)75 }76 var request = ChatRequest(77 model: model,78 systemPrompt: nil,79 messages: [Message(role: .user, text: "Reply with exactly: OK")],80 parameters: ChatParameters(maxTokens: 16),81 stream: false82 )83 request.parameters.temperature = nil84 _ = try await complete(request, apiKey: apiKey)85 }86 return Date().timeIntervalSince(start)87 }88}8990/// Errors mapped to clear, human-readable messages ("Invalid API key for Mistral",91/// "Rate limited — retrying in 20s").92enum ProviderError: LocalizedError {93 case invalidAPIKey(ProviderID)94 case rateLimited(ProviderID, retryAfter: TimeInterval?)95 case serverError(ProviderID, status: Int, message: String?)96 case badRequest(ProviderID, message: String?)97 case networkError(underlying: Error)98 case invalidResponse(ProviderID, detail: String)99 case missingAPIKey(ProviderID)100 case noModelAvailable(ProviderID)101 case cancelled102103 var errorDescription: String? {104 switch self {105 case .invalidAPIKey(let p):106 return "Invalid API key for \(p.displayName)."107 case .rateLimited(let p, let retryAfter):108 if let s = retryAfter {109 return "\(p.displayName) rate limited — retry in \(Int(s.rounded()))s."110 }111 return "\(p.displayName) rate limited — please retry shortly."112 case .serverError(let p, let status, let message):113 return "\(p.displayName) server error (\(status))\(message.map { ": \($0)" } ?? "")."114 case .badRequest(let p, let message):115 return "\(p.displayName) rejected the request\(message.map { ": \($0)" } ?? "")."116 case .networkError(let underlying):117 return "Network error: \(underlying.localizedDescription)"118 case .invalidResponse(let p, let detail):119 return "Unexpected response from \(p.displayName): \(detail)"120 case .missingAPIKey(let p):121 return "No API key configured for \(p.displayName). Add one in Settings → Providers & Keys."122 case .noModelAvailable(let p):123 return "No model available for \(p.displayName)."124 case .cancelled:125 return "Generation stopped."126 }127 }128129 /// Maps an HTTP status + provider error body to a typed error.130 static func from(status: Int, body: Data, provider: ProviderID) -> ProviderError {131 let message = Self.extractMessage(from: body)132 switch status {133 case 401, 403:134 return .invalidAPIKey(provider)135 case 429:136 return .rateLimited(provider, retryAfter: nil)137 case 400, 404, 422:138 return .badRequest(provider, message: message)139 default:140 return .serverError(provider, status: status, message: message)141 }142 }143144 /// Providers wrap errors differently ({"error":{"message":…}}, {"message":…},145 /// {"error":"…"}, Gemini arrays…). Try the common shapes.146 private static func extractMessage(from body: Data) -> String? {147 guard let obj = try? JSONSerialization.jsonObject(with: body) else {148 return String(data: body.prefix(300), encoding: .utf8)149 }150 if let dict = obj as? [String: Any] {151 if let err = dict["error"] as? [String: Any], let msg = err["message"] as? String {152 return msg153 }154 if let msg = dict["error"] as? String { return msg }155 if let msg = dict["message"] as? String { return msg }156 if let msg = dict["detail"] as? String { return msg }157 }158 if let arr = obj as? [[String: Any]],159 let err = arr.first?["error"] as? [String: Any],160 let msg = err["message"] as? String {161 return msg162 }163 return String(data: body.prefix(300), encoding: .utf8)164 }165}166