// // RequestRouter.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Resolves a requested model string — namespaced `provider/model-id`, an // unambiguous bare upstream ID, or a user alias — to a catalog model. // Aliases and fallback chains are user-configured (Phase 6 UI); disabled // models 404 through the API. // import Foundation struct RequestRouter: Sendable { /// Snapshot of the catalog (never hop to the main actor per request). let catalog: [AIModel] /// User aliases, e.g. "fast" → "cerebras/llama-3.3-70b". var aliases: [String: String] = [:] /// Namespaced IDs the user disabled (they 404 through the API). var disabledIDs: Set = [] /// User fallback chains: namespaced ID → ordered list of namespaced IDs to try next. var fallbackChains: [String: [String]] = [:] struct Resolution { let model: AIModel /// `provider/model-id` — always echoed back in responses. let namespacedID: String } enum RoutingError: Error { case unknownModel(String) case ambiguousModel(String, candidates: [String]) case disabledModel(String) } init( catalog: [AIModel] = ModelCatalogData.all, aliases: [String: String] = [:], disabledIDs: Set = [], fallbackChains: [String: [String]] = [:] ) { self.catalog = catalog self.aliases = aliases self.disabledIDs = disabledIDs self.fallbackChains = fallbackChains } static func namespacedID(for model: AIModel) -> String { "\(model.provider.rawValue)/\(model.id)" } /// Every model the router serves, in catalog order, with disabled ones flagged. var exposedModels: [(namespacedID: String, model: AIModel, disabled: Bool)] { catalog.map { model in let id = Self.namespacedID(for: model) return (id, model, disabledIDs.contains(id)) } } /// Resolves a requested model string. Order: alias → namespaced ID → bare ID. func resolve(_ requested: String) throws -> Resolution { let name = aliases[requested] ?? requested // Namespaced: the segment before the first "/" names a provider. // (Model IDs themselves may contain "/" — e.g. meta-llama/… on // Together/DeepInfra — which is exactly why the namespace is required // to be a known provider prefix.) if let slash = name.firstIndex(of: "/"), let provider = ProviderID(rawValue: String(name[name.startIndex.. Resolution { let id = Self.namespacedID(for: model) guard !disabledIDs.contains(id) else { throw RoutingError.disabledModel(id) } return Resolution(model: model, namespacedID: id) } }