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%
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