// // ModelCatalog.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Single source of truth for model data. Built-in entries are the exact // Zyquo Cloud catalog (ModelCatalogData.swift); dynamic /models refreshes and // user-defined custom models layer on top. Views and clients never hardcode // model IDs. Zyquo Agent adds the agent-capable filter and the default agent // model (docs/PROVIDER-REUSE.md §3). // import Combine import Foundation @MainActor final class ModelCatalog: ObservableObject { /// Built-in catalog (identical to Zyquo Cloud's — 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 } // MARK: - Agent-capable subset (Zyquo Agent addition) /// Models suitable for deep agentic, multi-step tool use — the curated /// subset of the shared catalog (docs/PROVIDER-REUSE.md §3). var agentCapableModels: [AIModel] { all.filter(\.agentCapable) } func agentCapableModels(for provider: ProviderID) -> [AIModel] { models(for: provider).filter(\.agentCapable) } /// Default agent model: Claude Sonnet 5 (falls back to the first /// agent-capable model if the catalog ever changes). var defaultAgentModel: AIModel? { model(id: AgentModelSupport.defaultModelID, provider: AgentModelSupport.defaultModelProvider) ?? agentCapableModels.first } /// Suggested agent default for one provider (bolded 🤖 entries in the doc). func defaultAgentModel(for provider: ProviderID) -> AIModel? { if let id = AgentModelSupport.providerDefaults[provider], let model = model(id: id, provider: provider), model.agentCapable { return model } return agentCapableModels(for: provider).first } /// The recommended top tier, surfaced first in the model chip. var recommendedAgentModels: [AIModel] { AgentModelSupport.recommendedKeys.compactMap { key in let parts = key.split(separator: "|", maxSplits: 1) guard parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])) else { return nil } return model(id: String(parts[1]), provider: provider) } } // MARK: - Dynamic listings /// 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 } }