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%
13.7 KB · 356 lines swift
Raw Blame History
1//2//  ModelsView.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The full namespaced catalog: search, provider filter, capability badges,9//  context + pricing columns, one-click ID copy, enable/disable (disabled10//  models 404 through the API), favorites, plus the aliases and fallback-11//  chain editors. Router config changes apply at the next server Start.12//1314import SwiftUI1516struct ModelsView: View {17    @EnvironmentObject private var catalog: ModelCatalog18    @EnvironmentObject private var routerConfig: RouterConfigStore19    @EnvironmentObject private var server: ServerController2021    @State private var search = ""22    @State private var providerFilter: ProviderID?23    @State private var showAliases = false24    @State private var chainModel: String?2526    private var filtered: [AIModel] {27        catalog.all28            .filter { model in29                if let providerFilter, model.provider != providerFilter { return false }30                guard !search.isEmpty else { return true }31                let namespaced = RequestRouter.namespacedID(for: model)32                return namespaced.localizedCaseInsensitiveContains(search)33                    || model.displayName.localizedCaseInsensitiveContains(search)34            }35            .sorted { first, second in36                let firstFavorite = routerConfig.isFavorite(RequestRouter.namespacedID(for: first))37                let secondFavorite = routerConfig.isFavorite(RequestRouter.namespacedID(for: second))38                if firstFavorite != secondFavorite { return firstFavorite }39                return false // keep catalog order otherwise40            }41    }4243    var body: some View {44        VStack(alignment: .leading, spacing: 0) {45            HStack(alignment: .center) {46                SectionHeader(47                    title: "Models",48                    subtitle: "\(catalog.all.count) models across \(ProviderID.builtIn.count) providers"49                )50                Spacer()51                Button("Aliases…") { showAliases = true }52                Picker("", selection: $providerFilter) {53                    Text("All providers").tag(ProviderID?.none)54                    ForEach(ProviderID.builtIn) { provider in55                        Text(provider.displayName).tag(ProviderID?.some(provider))56                    }57                }58                .labelsHidden()59                .frame(width: 160)60                TextField("Search models…", text: $search)61                    .textFieldStyle(.roundedBorder)62                    .frame(width: 200)63            }64            .padding(ZyquoMetrics.contentInset)6566            if server.isRunning, configDiffers {67                Label("Routing changes apply the next time the server starts.", systemImage: "info.circle")68                    .font(ZyquoFont.caption)69                    .foregroundStyle(ZyquoColor.warning)70                    .padding(.horizontal, ZyquoMetrics.contentInset)71                    .padding(.bottom, ZyquoSpacing.xs)72            }7374            ZyquoHairline()7576            ScrollView {77                LazyVStack(spacing: 0) {78                    ForEach(filtered) { model in79                        ModelRow(model: model, editChain: { chainModel = $0 })80                        ZyquoHairline()81                            .padding(.leading, ZyquoMetrics.contentInset)82                    }83                }84            }85        }86        .sheet(isPresented: $showAliases) {87            AliasEditor()88        }89        .sheet(item: Binding(90            get: { chainModel.map(ChainTarget.init) },91            set: { chainModel = $0?.id }92        )) { target in93            ChainEditor(namespacedID: target.id)94        }95    }9697    /// Rough signal that in-memory config differs from what the server took.98    private var configDiffers: Bool {99        !routerConfig.config.disabledIDs.isEmpty100            || !routerConfig.config.aliases.isEmpty101            || !routerConfig.config.fallbackChains.isEmpty102    }103}104105private struct ChainTarget: Identifiable {106    let id: String107}108109// MARK: - Row110111private struct ModelRow: View {112    let model: AIModel113    let editChain: (String) -> Void114    @EnvironmentObject private var routerConfig: RouterConfigStore115    @State private var hovering = false116117    private var namespacedID: String { RequestRouter.namespacedID(for: model) }118    private var disabled: Bool { routerConfig.isDisabled(namespacedID) }119120    var body: some View {121        HStack(spacing: ZyquoSpacing.md) {122            Button {123                routerConfig.toggleFavorite(namespacedID)124            } label: {125                Image(systemName: routerConfig.isFavorite(namespacedID) ? "star.fill" : "star")126                    .font(.system(size: 11))127                    .foregroundStyle(routerConfig.isFavorite(namespacedID) ? ZyquoColor.warning : ZyquoColor.textTertiary)128            }129            .buttonStyle(.plain)130            .opacity(hovering || routerConfig.isFavorite(namespacedID) ? 1 : 0.25)131132            VStack(alignment: .leading, spacing: 3) {133                HStack(spacing: ZyquoSpacing.xs) {134                    Text(namespacedID)135                        .font(ZyquoFont.mono(size: 12.5, weight: .medium))136                        .foregroundStyle(disabled ? ZyquoColor.textTertiary : ZyquoColor.textPrimary)137                        .strikethrough(disabled, color: ZyquoColor.textTertiary)138                        .lineLimit(1)139                        .truncationMode(.middle)140                    if hovering {141                        CopyButton(value: namespacedID)142                    }143                    if model.isRecommended {144                        CapabilityBadge(label: "FEATURED", tint: ZyquoColor.accent)145                    }146                    if model.isLegacy {147                        CapabilityBadge(label: "LEGACY", tint: ZyquoColor.textTertiary)148                    }149                    if let chain = routerConfig.config.fallbackChains[namespacedID], !chain.isEmpty {150                        CapabilityBadge(label: "CHAIN +\(chain.count)", tint: ZyquoColor.graphite)151                    }152                }153                HStack(spacing: ZyquoSpacing.xs) {154                    ProviderBadge(provider: model.provider)155                    Text(model.displayName)156                        .font(ZyquoFont.caption)157                        .foregroundStyle(ZyquoColor.textTertiary)158                }159            }160161            Spacer()162163            if hovering {164                Button("Fallbacks…") { editChain(namespacedID) }165                    .font(ZyquoFont.caption)166            }167168            HStack(spacing: ZyquoSpacing.xxs) {169                if model.capabilities.vision { CapabilityBadge(label: "VISION") }170                if model.capabilities.tools { CapabilityBadge(label: "TOOLS") }171                if model.capabilities.reasoning { CapabilityBadge(label: "REASONING", tint: ZyquoColor.accent) }172                if model.capabilities.citations { CapabilityBadge(label: "SEARCH") }173            }174175            Text(model.contextBadge)176                .font(ZyquoFont.mono(size: 11))177                .foregroundStyle(ZyquoColor.textSecondary)178                .frame(width: 62, alignment: .trailing)179180            Text(priceText)181                .font(ZyquoFont.mono(size: 11))182                .foregroundStyle(ZyquoColor.textSecondary)183                .frame(width: 112, alignment: .trailing)184185            Toggle("", isOn: Binding(186                get: { !disabled },187                set: { routerConfig.setDisabled(!$0, for: namespacedID) }188            ))189            .toggleStyle(.switch)190            .controlSize(.mini)191            .labelsHidden()192            .help(disabled ? "Disabled — 404s through the API" : "Enabled")193        }194        .padding(.horizontal, ZyquoMetrics.contentInset)195        .padding(.vertical, ZyquoSpacing.xs)196        .background(hovering ? ZyquoColor.surfaceSecondary : .clear)197        .onHover { hover in198            withAnimation(ZyquoMotion.hover) { hovering = hover }199        }200    }201202    private var priceText: String {203        guard let pricing = model.pricing else { return "—" }204        return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok)205    }206}207208// MARK: - Alias editor209210private struct AliasEditor: View {211    @EnvironmentObject private var routerConfig: RouterConfigStore212    @EnvironmentObject private var catalog: ModelCatalog213    @Environment(\.dismiss) private var dismiss214215    @State private var newAlias = ""216    @State private var newTarget = ""217218    var body: some View {219        VStack(alignment: .leading, spacing: ZyquoSpacing.md) {220            Text("Aliases")221                .font(ZyquoFont.title)222            Text("Friendly names that resolve to a model — e.g. `fast` → `cerebras/…`.")223                .font(ZyquoFont.body())224                .foregroundStyle(ZyquoColor.textSecondary)225226            HStack(spacing: ZyquoSpacing.xs) {227                TextField("alias (e.g. fast)", text: $newAlias)228                    .textFieldStyle(.roundedBorder)229                    .font(ZyquoFont.mono(size: 12))230                    .frame(width: 140)231                Text("→")232                    .foregroundStyle(ZyquoColor.textTertiary)233                Picker("", selection: $newTarget) {234                    Text("Choose a model…").tag("")235                    ForEach(catalog.all) { model in236                        Text(RequestRouter.namespacedID(for: model))237                            .tag(RequestRouter.namespacedID(for: model))238                    }239                }240                .labelsHidden()241                Button("Add") {242                    routerConfig.setAlias(newAlias, target: newTarget)243                    newAlias = ""244                    newTarget = ""245                }246                .disabled(newAlias.trimmingCharacters(in: .whitespaces).isEmpty || newTarget.isEmpty)247            }248249            if routerConfig.config.aliases.isEmpty {250                Text("No aliases yet.")251                    .font(ZyquoFont.caption)252                    .foregroundStyle(ZyquoColor.textTertiary)253            } else {254                ScrollView {255                    VStack(spacing: 0) {256                        ForEach(routerConfig.config.aliases.sorted(by: { $0.key < $1.key }), id: \.key) { alias, target in257                            HStack {258                                Text(alias)259                                    .font(ZyquoFont.mono(size: 12, weight: .medium))260                                Text("→ \(target)")261                                    .font(ZyquoFont.mono(size: 12))262                                    .foregroundStyle(ZyquoColor.textSecondary)263                                Spacer()264                                Button("Remove") { routerConfig.removeAlias(alias) }265                                    .font(ZyquoFont.caption)266                            }267                            .padding(.vertical, ZyquoSpacing.xxs)268                            ZyquoHairline()269                        }270                    }271                }272                .frame(maxHeight: 220)273            }274275            HStack {276                Spacer()277                Button("Done") { dismiss() }278                    .keyboardShortcut(.defaultAction)279            }280        }281        .padding(ZyquoSpacing.xl)282        .frame(width: 560)283    }284}285286// MARK: - Fallback-chain editor287288private struct ChainEditor: View {289    let namespacedID: String290    @EnvironmentObject private var routerConfig: RouterConfigStore291    @EnvironmentObject private var catalog: ModelCatalog292    @Environment(\.dismiss) private var dismiss293294    @State private var chain: [String] = []295    @State private var addition = ""296297    var body: some View {298        VStack(alignment: .leading, spacing: ZyquoSpacing.md) {299            Text("Fallback chain")300                .font(ZyquoFont.title)301            Text("When `\(namespacedID)` fails upstream, the router tries these in order. The response reports the model that actually answered.")302                .font(ZyquoFont.body())303                .foregroundStyle(ZyquoColor.textSecondary)304305            List {306                ForEach(chain, id: \.self) { model in307                    Text(model)308                        .font(ZyquoFont.mono(size: 12))309                }310                .onMove { indices, offset in311                    chain.move(fromOffsets: indices, toOffset: offset)312                }313                .onDelete { indices in314                    chain.remove(atOffsets: indices)315                }316            }317            .frame(height: 160)318319            HStack(spacing: ZyquoSpacing.xs) {320                Picker("", selection: $addition) {321                    Text("Add a fallback…").tag("")322                    ForEach(catalog.all) { model in323                        let id = RequestRouter.namespacedID(for: model)324                        if id != namespacedID, !chain.contains(id) {325                            Text(id).tag(id)326                        }327                    }328                }329                .labelsHidden()330                Button("Add") {331                    guard !addition.isEmpty else { return }332                    chain.append(addition)333                    addition = ""334                }335                .disabled(addition.isEmpty)336            }337338            HStack {339                Button("Clear chain", role: .destructive) { chain = [] }340                Spacer()341                Button("Cancel") { dismiss() }342                Button("Save") {343                    routerConfig.setChain(for: namespacedID, chain: chain)344                    dismiss()345                }346                .keyboardShortcut(.defaultAction)347            }348        }349        .padding(ZyquoSpacing.xl)350        .frame(width: 560)351        .onAppear {352            chain = routerConfig.config.fallbackChains[namespacedID] ?? []353        }354    }355}356