// // ModelCatalog.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Single source of truth for model data. Built-in entries are generated from // docs/PROVIDERS.md (see ModelCatalogData.swift); dynamic /models refreshes and // user-defined custom models layer on top. Views and clients never hardcode // model IDs. // import Foundation @MainActor final class ModelCatalog: ObservableObject { /// Built-in catalog (generated from docs/PROVIDERS.md — keep in sync). @Published private(set) var builtIn: [AIModel] = ModelCatalogData.all /// User-defined custom models (custom ID + base URL). @Published var customModels: [AIModel] = [] /// Model IDs confirmed live by the last dynamic refresh, per provider. @Published private(set) var liveModelIDs: [ProviderID: Set] = [:] /// Favorite model IDs, pinned at the top of pickers. @Published var favoriteIDs: Set = [] var all: [AIModel] { builtIn + customModels } func models(for provider: ProviderID) -> [AIModel] { all.filter { $0.provider == provider } .sorted { rank($0) < rank($1) } } func model(id: String, provider: ProviderID) -> AIModel? { all.first { $0.id == id && $0.provider == provider } } /// Cheapest non-legacy chat model for a provider (used for key tests and /// auto-title generation). Non-reasoning models are preferred — reasoning /// models burn their token budget thinking, useless for tiny utility calls. func cheapestModel(for provider: ProviderID) -> AIModel? { let candidates = models(for: provider).filter { !$0.isLegacy } let plain = candidates.filter { !$0.capabilities.reasoning } return (plain.isEmpty ? candidates : plain) .min { ($0.pricing?.outputPerMTok ?? .infinity) < ($1.pricing?.outputPerMTok ?? .infinity) } } /// Default model offered for new conversations. var defaultModel: AIModel? { all.first { $0.isRecommended } ?? all.first } /// Merges a dynamic /models listing: known models are marked live; unknown /// IDs are surfaced so the user can add them. func applyLiveListing(_ ids: [String], for provider: ProviderID) { liveModelIDs[provider] = Set(ids) } /// IDs returned by the provider but absent from the built-in catalog. func unknownLiveIDs(for provider: ProviderID) -> [String] { guard let live = liveModelIDs[provider] else { return [] } let known = Set(models(for: provider).map(\.id)) return live.subtracting(known).sorted() } private func rank(_ model: AIModel) -> Int { if favoriteIDs.contains(model.id) { return 0 } if model.isRecommended { return 1 } if model.isLegacy { return 3 } return 2 } }