// // ModelsView.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The full namespaced catalog: search, provider filter, capability badges, // context + pricing columns, one-click ID copy, enable/disable (disabled // models 404 through the API), favorites, plus the aliases and fallback- // chain editors. Router config changes apply at the next server Start. // import SwiftUI struct ModelsView: View { @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var routerConfig: RouterConfigStore @EnvironmentObject private var server: ServerController @State private var search = "" @State private var providerFilter: ProviderID? @State private var showAliases = false @State private var chainModel: String? private var filtered: [AIModel] { catalog.all .filter { model in if let providerFilter, model.provider != providerFilter { return false } guard !search.isEmpty else { return true } let namespaced = RequestRouter.namespacedID(for: model) return namespaced.localizedCaseInsensitiveContains(search) || model.displayName.localizedCaseInsensitiveContains(search) } .sorted { first, second in let firstFavorite = routerConfig.isFavorite(RequestRouter.namespacedID(for: first)) let secondFavorite = routerConfig.isFavorite(RequestRouter.namespacedID(for: second)) if firstFavorite != secondFavorite { return firstFavorite } return false // keep catalog order otherwise } } var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center) { SectionHeader( title: "Models", subtitle: "\(catalog.all.count) models across \(ProviderID.builtIn.count) providers" ) Spacer() Button("Aliases…") { showAliases = true } Picker("", selection: $providerFilter) { Text("All providers").tag(ProviderID?.none) ForEach(ProviderID.builtIn) { provider in Text(provider.displayName).tag(ProviderID?.some(provider)) } } .labelsHidden() .frame(width: 160) TextField("Search models…", text: $search) .textFieldStyle(.roundedBorder) .frame(width: 200) } .padding(ZyquoMetrics.contentInset) if server.isRunning, configDiffers { Label("Routing changes apply the next time the server starts.", systemImage: "info.circle") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.warning) .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.bottom, ZyquoSpacing.xs) } ZyquoHairline() ScrollView { LazyVStack(spacing: 0) { ForEach(filtered) { model in ModelRow(model: model, editChain: { chainModel = $0 }) ZyquoHairline() .padding(.leading, ZyquoMetrics.contentInset) } } } } .sheet(isPresented: $showAliases) { AliasEditor() } .sheet(item: Binding( get: { chainModel.map(ChainTarget.init) }, set: { chainModel = $0?.id } )) { target in ChainEditor(namespacedID: target.id) } } /// Rough signal that in-memory config differs from what the server took. private var configDiffers: Bool { !routerConfig.config.disabledIDs.isEmpty || !routerConfig.config.aliases.isEmpty || !routerConfig.config.fallbackChains.isEmpty } } private struct ChainTarget: Identifiable { let id: String } // MARK: - Row private struct ModelRow: View { let model: AIModel let editChain: (String) -> Void @EnvironmentObject private var routerConfig: RouterConfigStore @State private var hovering = false private var namespacedID: String { RequestRouter.namespacedID(for: model) } private var disabled: Bool { routerConfig.isDisabled(namespacedID) } var body: some View { HStack(spacing: ZyquoSpacing.md) { Button { routerConfig.toggleFavorite(namespacedID) } label: { Image(systemName: routerConfig.isFavorite(namespacedID) ? "star.fill" : "star") .font(.system(size: 11)) .foregroundStyle(routerConfig.isFavorite(namespacedID) ? ZyquoColor.warning : ZyquoColor.textTertiary) } .buttonStyle(.plain) .opacity(hovering || routerConfig.isFavorite(namespacedID) ? 1 : 0.25) VStack(alignment: .leading, spacing: 3) { HStack(spacing: ZyquoSpacing.xs) { Text(namespacedID) .font(ZyquoFont.mono(size: 12.5, weight: .medium)) .foregroundStyle(disabled ? ZyquoColor.textTertiary : ZyquoColor.textPrimary) .strikethrough(disabled, color: ZyquoColor.textTertiary) .lineLimit(1) .truncationMode(.middle) if hovering { CopyButton(value: namespacedID) } if model.isRecommended { CapabilityBadge(label: "FEATURED", tint: ZyquoColor.accent) } if model.isLegacy { CapabilityBadge(label: "LEGACY", tint: ZyquoColor.textTertiary) } if let chain = routerConfig.config.fallbackChains[namespacedID], !chain.isEmpty { CapabilityBadge(label: "CHAIN +\(chain.count)", tint: ZyquoColor.graphite) } } HStack(spacing: ZyquoSpacing.xs) { ProviderBadge(provider: model.provider) Text(model.displayName) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } Spacer() if hovering { Button("Fallbacks…") { editChain(namespacedID) } .font(ZyquoFont.caption) } HStack(spacing: ZyquoSpacing.xxs) { if model.capabilities.vision { CapabilityBadge(label: "VISION") } if model.capabilities.tools { CapabilityBadge(label: "TOOLS") } if model.capabilities.reasoning { CapabilityBadge(label: "REASONING", tint: ZyquoColor.accent) } if model.capabilities.citations { CapabilityBadge(label: "SEARCH") } } Text(model.contextBadge) .font(ZyquoFont.mono(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) .frame(width: 62, alignment: .trailing) Text(priceText) .font(ZyquoFont.mono(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) .frame(width: 112, alignment: .trailing) Toggle("", isOn: Binding( get: { !disabled }, set: { routerConfig.setDisabled(!$0, for: namespacedID) } )) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() .help(disabled ? "Disabled — 404s through the API" : "Enabled") } .padding(.horizontal, ZyquoMetrics.contentInset) .padding(.vertical, ZyquoSpacing.xs) .background(hovering ? ZyquoColor.surfaceSecondary : .clear) .onHover { hover in withAnimation(ZyquoMotion.hover) { hovering = hover } } } private var priceText: String { guard let pricing = model.pricing else { return "—" } return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok) } } // MARK: - Alias editor private struct AliasEditor: View { @EnvironmentObject private var routerConfig: RouterConfigStore @EnvironmentObject private var catalog: ModelCatalog @Environment(\.dismiss) private var dismiss @State private var newAlias = "" @State private var newTarget = "" var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.md) { Text("Aliases") .font(ZyquoFont.title) Text("Friendly names that resolve to a model — e.g. `fast` → `cerebras/…`.") .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.textSecondary) HStack(spacing: ZyquoSpacing.xs) { TextField("alias (e.g. fast)", text: $newAlias) .textFieldStyle(.roundedBorder) .font(ZyquoFont.mono(size: 12)) .frame(width: 140) Text("→") .foregroundStyle(ZyquoColor.textTertiary) Picker("", selection: $newTarget) { Text("Choose a model…").tag("") ForEach(catalog.all) { model in Text(RequestRouter.namespacedID(for: model)) .tag(RequestRouter.namespacedID(for: model)) } } .labelsHidden() Button("Add") { routerConfig.setAlias(newAlias, target: newTarget) newAlias = "" newTarget = "" } .disabled(newAlias.trimmingCharacters(in: .whitespaces).isEmpty || newTarget.isEmpty) } if routerConfig.config.aliases.isEmpty { Text("No aliases yet.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } else { ScrollView { VStack(spacing: 0) { ForEach(routerConfig.config.aliases.sorted(by: { $0.key < $1.key }), id: \.key) { alias, target in HStack { Text(alias) .font(ZyquoFont.mono(size: 12, weight: .medium)) Text("→ \(target)") .font(ZyquoFont.mono(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) Spacer() Button("Remove") { routerConfig.removeAlias(alias) } .font(ZyquoFont.caption) } .padding(.vertical, ZyquoSpacing.xxs) ZyquoHairline() } } } .frame(maxHeight: 220) } HStack { Spacer() Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) } } .padding(ZyquoSpacing.xl) .frame(width: 560) } } // MARK: - Fallback-chain editor private struct ChainEditor: View { let namespacedID: String @EnvironmentObject private var routerConfig: RouterConfigStore @EnvironmentObject private var catalog: ModelCatalog @Environment(\.dismiss) private var dismiss @State private var chain: [String] = [] @State private var addition = "" var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.md) { Text("Fallback chain") .font(ZyquoFont.title) Text("When `\(namespacedID)` fails upstream, the router tries these in order. The response reports the model that actually answered.") .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.textSecondary) List { ForEach(chain, id: \.self) { model in Text(model) .font(ZyquoFont.mono(size: 12)) } .onMove { indices, offset in chain.move(fromOffsets: indices, toOffset: offset) } .onDelete { indices in chain.remove(atOffsets: indices) } } .frame(height: 160) HStack(spacing: ZyquoSpacing.xs) { Picker("", selection: $addition) { Text("Add a fallback…").tag("") ForEach(catalog.all) { model in let id = RequestRouter.namespacedID(for: model) if id != namespacedID, !chain.contains(id) { Text(id).tag(id) } } } .labelsHidden() Button("Add") { guard !addition.isEmpty else { return } chain.append(addition) addition = "" } .disabled(addition.isEmpty) } HStack { Button("Clear chain", role: .destructive) { chain = [] } Spacer() Button("Cancel") { dismiss() } Button("Save") { routerConfig.setChain(for: namespacedID, chain: chain) dismiss() } .keyboardShortcut(.defaultAction) } } .padding(ZyquoSpacing.xl) .frame(width: 560) .onAppear { chain = routerConfig.config.fallbackChains[namespacedID] ?? [] } } }