SPB Git

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%
6.9 KB · 166 lines swift
Raw Blame History
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