SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%
3.5 KB · 100 lines swift
Raw Blame History
1//2//  RequestRouter.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Resolves a requested model string — namespaced `provider/model-id`, an9//  unambiguous bare upstream ID, or a user alias — to a catalog model.10//  Aliases and fallback chains are user-configured (Phase 6 UI); disabled11//  models 404 through the API.12//1314import Foundation1516struct RequestRouter: Sendable {17    /// Snapshot of the catalog (never hop to the main actor per request).18    let catalog: [AIModel]19    /// User aliases, e.g. "fast" → "cerebras/llama-3.3-70b".20    var aliases: [String: String] = [:]21    /// Namespaced IDs the user disabled (they 404 through the API).22    var disabledIDs: Set<String> = []23    /// User fallback chains: namespaced ID → ordered list of namespaced IDs to try next.24    var fallbackChains: [String: [String]] = [:]2526    struct Resolution {27        let model: AIModel28        /// `provider/model-id` — always echoed back in responses.29        let namespacedID: String30    }3132    enum RoutingError: Error {33        case unknownModel(String)34        case ambiguousModel(String, candidates: [String])35        case disabledModel(String)36    }3738    init(39        catalog: [AIModel] = ModelCatalogData.all,40        aliases: [String: String] = [:],41        disabledIDs: Set<String> = [],42        fallbackChains: [String: [String]] = [:]43    ) {44        self.catalog = catalog45        self.aliases = aliases46        self.disabledIDs = disabledIDs47        self.fallbackChains = fallbackChains48    }4950    static func namespacedID(for model: AIModel) -> String {51        "\(model.provider.rawValue)/\(model.id)"52    }5354    /// Every model the router serves, in catalog order, with disabled ones flagged.55    var exposedModels: [(namespacedID: String, model: AIModel, disabled: Bool)] {56        catalog.map { model in57            let id = Self.namespacedID(for: model)58            return (id, model, disabledIDs.contains(id))59        }60    }6162    /// Resolves a requested model string. Order: alias → namespaced ID → bare ID.63    func resolve(_ requested: String) throws -> Resolution {64        let name = aliases[requested] ?? requested6566        // Namespaced: the segment before the first "/" names a provider.67        // (Model IDs themselves may contain "/" — e.g. meta-llama/… on68        // Together/DeepInfra — which is exactly why the namespace is required69        // to be a known provider prefix.)70        if let slash = name.firstIndex(of: "/"),71           let provider = ProviderID(rawValue: String(name[name.startIndex..<slash])) {72            let bareID = String(name[name.index(after: slash)...])73            if let model = catalog.first(where: { $0.provider == provider && $0.id == bareID }) {74                return try admit(model)75            }76            throw RoutingError.unknownModel(requested)77        }7879        // Bare upstream ID, accepted when unambiguous across providers.80        let matches = catalog.filter { $0.id == name }81        switch matches.count {82        case 0:83            throw RoutingError.unknownModel(requested)84        case 1:85            return try admit(matches[0])86        default:87            throw RoutingError.ambiguousModel(88                requested,89                candidates: matches.map(Self.namespacedID(for:))90            )91        }92    }9394    private func admit(_ model: AIModel) throws -> Resolution {95        let id = Self.namespacedID(for: model)96        guard !disabledIDs.contains(id) else { throw RoutingError.disabledModel(id) }97        return Resolution(model: model, namespacedID: id)98    }99}100