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%

phase6: features — router config + editors, request log + inspector, live charts, menu bar extra, palette, settings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 days ago (Jul 31, 2026) parent 2fc7148

Showing 19 changed files with +1,439 and −130

modified Sources/ZyquoRouter/App/AppEnvironment.swift +1 −0
@@ -18,6 +18,7 @@ final class AppEnvironment: ObservableObject {
18 18 let appearance = AppearanceStore()
19 19 let vault = KeyVaultStore()
20 20 let localKeys = LocalKeysStore()
21 + let routerConfig = RouterConfigStore()
21 22 }
22 23
23 24 /// Navigator sections (⌘1–6).
modified Sources/ZyquoRouter/App/Main.swift +6 −1
@@ -28,8 +28,13 @@ enum Main {
28 28 let port = arguments.indices.contains(flagIndex + 1) ? Int(arguments[flagIndex + 1]) ?? 8787 : 8787
29 29 Task.detached {
30 30 let localKeys = PersistenceService.shared.load([APIKeyRecord].self, from: "local-keys.json") ?? []
31 + let routerConfig = PersistenceService.shared.load(RouterConfig.self, from: RouterConfig.fileName) ?? RouterConfig()
31 32 let routes = Routes(
32 router: RequestRouter(),
33 + router: RequestRouter(
34 + aliases: routerConfig.aliases,
35 + disabledIDs: routerConfig.disabledIDs,
36 + fallbackChains: routerConfig.fallbackChains
37 + ),
33 38 auth: AuthMiddleware(keys: localKeys)
34 39 )
35 40 let server = HTTPServer(host: "127.0.0.1", port: port) { request in
modified Sources/ZyquoRouter/App/ZyquoRouterApp.swift +87 −0
@@ -13,6 +13,8 @@ struct ZyquoRouterApp: App {
13 13 @StateObject private var environment = AppEnvironment()
14 14 @AppStorage("autoStartServer") private var autoStart = false
15 15
16 + @AppStorage("menuBarExtraEnabled") private var menuBarExtraEnabled = true
17 +
16 18 var body: some Scene {
17 19 WindowGroup("Zyquo Router") {
18 20 MainWindowView()
@@ -21,6 +23,7 @@ struct ZyquoRouterApp: App {
21 23 .environmentObject(environment.appearance)
22 24 .environmentObject(environment.vault)
23 25 .environmentObject(environment.localKeys)
26 + .environmentObject(environment.routerConfig)
24 27 .onAppear {
25 28 if autoStart, !environment.server.isRunning {
26 29 environment.server.start()
@@ -42,8 +45,86 @@ struct ZyquoRouterApp: App {
42 45 .environmentObject(environment.appearance)
43 46 .environmentObject(environment.vault)
44 47 .environmentObject(environment.localKeys)
48 + .environmentObject(environment.routerConfig)
49 + }
50 +
51 + MenuBarExtra(isInserted: $menuBarExtraEnabled) {
52 + MenuBarContent()
53 + .environmentObject(environment.server)
54 + } label: {
55 + Image(nsImage: Self.menuBarImage)
56 + }
57 + }
58 +
59 + /// Template glyph rendered by scripts/generate-icon.sh; falls back to an
60 + /// SF Symbol during `swift run` (no bundle resources).
61 + private static let menuBarImage: NSImage = {
62 + if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "png"),
63 + let image = NSImage(contentsOf: url) {
64 + image.isTemplate = true
65 + image.size = NSSize(width: 18, height: 18)
66 + return image
67 + }
68 + let fallback = NSImage(
69 + systemSymbolName: "point.3.connected.trianglepath.dotted",
70 + accessibilityDescription: "Zyquo Router"
71 + ) ?? NSImage()
72 + fallback.isTemplate = true
73 + return fallback
74 + }()
75 +}
76 +
77 +/// Menu bar extra: status, Start/Stop, live numbers, copy endpoint.
78 +private struct MenuBarContent: View {
79 + @EnvironmentObject private var server: ServerController
80 + @State private var summary = ""
81 +
82 + var body: some View {
83 + Group {
84 + Text(statusLine)
85 + if !summary.isEmpty {
86 + Text(summary)
87 + }
88 + Divider()
89 + Button(server.isRunning ? "Stop Server" : "Start Server") {
90 + server.toggle()
91 + }
92 + .keyboardShortcut("r")
93 + Button("Copy Endpoint URL") {
94 + NSPasteboard.general.clearContents()
95 + NSPasteboard.general.setString(server.endpointURL, forType: .string)
96 + }
97 + Divider()
98 + Button("Open Zyquo Router") {
99 + NSApp.activate(ignoringOtherApps: true)
100 + }
101 + Button("Quit") {
102 + NSApp.terminate(nil)
103 + }
104 + }
105 + .task {
106 + while !Task.isCancelled {
107 + await refreshSummary()
108 + try? await Task.sleep(nanoseconds: 5_000_000_000)
109 + }
45 110 }
46 111 }
112 +
113 + private var statusLine: String {
114 + switch server.state {
115 + case .running(let port): return "● Running on :\(port)"
116 + case .starting: return "◐ Starting…"
117 + case .failed: return "○ Failed to start"
118 + case .stopped: return "○ Stopped"
119 + }
120 + }
121 +
122 + private func refreshSummary() async {
123 + let meter = server.usageMeter
124 + let today = await meter.totals(since: Calendar.current.startOfDay(for: Date()))
125 + let lastMinute = await meter.requestsPerMinute(minutes: 1).first ?? 0
126 + summary = String(format: "%d req/min · $%.2f today", lastMinute, today.cost)
127 + }
47 128 }
48 129
49 130 /// App-level menu commands: ⌘1–6 sections, ⌘⇧C copy endpoint.
@@ -52,6 +133,12 @@ struct AppCommands: Commands {
52 133 let server: ServerController
53 134
54 135 var body: some Commands {
136 + CommandMenu("Server") {
137 + Button(server.isRunning ? "Stop Server" : "Start Server") {
138 + server.toggle()
139 + }
140 + .keyboardShortcut("r", modifiers: .command)
141 + }
55 142 CommandMenu("Go") {
56 143 ForEach(Array(AppSection.allCases.enumerated()), id: \.element.id) { index, section in
57 144 Button(section.rawValue) {
modified Sources/ZyquoRouter/Router/UsageMeter.swift +31 −0
@@ -55,11 +55,42 @@ struct UsageRecord: Codable, Identifiable, Sendable {
55 55
56 56 actor UsageMeter {
57 57 private(set) var records: [UsageRecord] = []
58 + /// Streams currently being forwarded to clients (dashboard tile).
59 + private(set) var activeStreams = 0
58 60
59 61 func record(_ entry: UsageRecord) {
60 62 records.append(entry)
61 63 }
62 64
65 + func streamBegan() {
66 + activeStreams += 1
67 + }
68 +
69 + func streamEnded() {
70 + activeStreams = max(0, activeStreams - 1)
71 + }
72 +
73 + /// Requests per minute over the trailing window (sparkline buckets).
74 + func requestsPerMinute(minutes: Int) -> [Int] {
75 + let now = Date()
76 + var buckets = [Int](repeating: 0, count: minutes)
77 + for record in records {
78 + let age = now.timeIntervalSince(record.date)
79 + guard age >= 0, age < Double(minutes * 60) else { continue }
80 + buckets[minutes - 1 - Int(age / 60)] += 1
81 + }
82 + return buckets
83 + }
84 +
85 + /// Share of today's requests per provider (breakdown bar).
86 + func providerBreakdown(since cutoff: Date) -> [(provider: ProviderID, count: Int)] {
87 + var counts: [ProviderID: Int] = [:]
88 + for record in records where record.date >= cutoff {
89 + counts[record.provider, default: 0] += 1
90 + }
91 + return counts.sorted { $0.value > $1.value }.map { ($0.key, $0.value) }
92 + }
93 +
63 94 /// Totals since a cutoff (today's tiles on the dashboard).
64 95 func totals(since cutoff: Date) -> (requests: Int, usage: TokenUsage, cost: Double, errors: Int) {
65 96 var usage = TokenUsage()
modified Sources/ZyquoRouter/Server/ChatCompletionsRoute.swift +104 −34
@@ -21,8 +21,20 @@ struct ChatCompletionsRoute {
21 21 let router: RequestRouter
22 22 let providerKey: @Sendable (ProviderID) -> String?
23 23 let usageMeter: UsageMeter
24 + let requestLog: RequestLogStore
24 25 let retryPolicy: RetryPolicy
25 26
27 + /// Cap on stored body previews in the request log (inspector display).
28 + private static let previewLimit = 20_000
29 +
30 + static func prettyJSON(_ data: Data) -> String {
31 + guard let object = try? JSONSerialization.jsonObject(with: data),
32 + let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) else {
33 + return String(decoding: data.prefix(previewLimit), as: UTF8.self)
34 + }
35 + return String(decoding: pretty.prefix(previewLimit), as: UTF8.self)
36 + }
37 +
26 38 func handle(_ request: RouteRequest, localKey: APIKeyRecord?) async -> RouteResult {
27 39 // Parse.
28 40 let chat: ChatCompletionRequest
@@ -71,10 +83,11 @@ struct ChatCompletionsRoute {
71 83 }
72 84
73 85 // Try candidates in order; report the actually-used model honestly.
86 + let requestPreview = Self.prettyJSON(request.body)
74 87 var lastFailure: ProviderError = .noModelAvailable(candidates[0].model.provider)
75 88 for (index, resolution) in candidates.enumerated() {
76 89 let isLastCandidate = index == candidates.count - 1
77 switch await attempt(chat: chat, resolution: resolution, request: request, localKey: localKey) {
90 + switch await attempt(chat: chat, resolution: resolution, localKey: localKey, requestPreview: requestPreview) {
78 91 case .success(let result):
79 92 return result
80 93 case .failure(let error):
@@ -97,8 +110,8 @@ struct ChatCompletionsRoute {
97 110 private func attempt(
98 111 chat: ChatCompletionRequest,
99 112 resolution: RequestRouter.Resolution,
100 request: RouteRequest,
101 localKey: APIKeyRecord?
113 + localKey: APIKeyRecord?,
114 + requestPreview: String
102 115 ) async -> AttemptOutcome {
103 116 let model = resolution.model
104 117
@@ -123,12 +136,12 @@ struct ChatCompletionsRoute {
123 136 let mustStreamUpstream = model.parameterSupport.requiresStreaming
124 137
125 138 if clientWantsStream {
126 return await streamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey)
139 + return await streamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey, requestPreview: requestPreview)
127 140 }
128 141 if mustStreamUpstream {
129 return await aggregatedStreamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey)
142 + return await aggregatedStreamingAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey, requestPreview: requestPreview)
130 143 }
131 return await bufferedAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey)
144 + return await bufferedAttempt(chat: chat, resolution: resolution, call: call, localKey: localKey, requestPreview: requestPreview)
132 145 }
133 146
134 147 private func shouldFallback(on error: ProviderError) -> Bool {
@@ -161,7 +174,8 @@ struct ChatCompletionsRoute {
161 174 chat: ChatCompletionRequest,
162 175 resolution: RequestRouter.Resolution,
163 176 call: UpstreamCall,
164 localKey: APIKeyRecord?
177 + localKey: APIKeyRecord?,
178 + requestPreview: String
165 179 ) async -> AttemptOutcome {
166 180 let started = Date()
167 181 let emitter = ChunkEmitter(model: resolution.namespacedID)
@@ -191,18 +205,23 @@ struct ChatCompletionsRoute {
191 205 response = normalized
192 206 }
193 207
194 await meter(response: response, resolution: resolution, localKey: localKey, started: started, streamed: false, status: 200)
208 + let body = ChunkEmitter.serialize(response)
209 + await meter(
210 + response: response, resolution: resolution, localKey: localKey,
211 + started: started, streamed: false, status: 200,
212 + requestBody: requestPreview, responseBody: Self.prettyJSON(body)
213 + )
195 214 return .success(.complete(
196 215 status: .ok,
197 216 headers: [("Content-Type", "application/json")],
198 body: ChunkEmitter.serialize(response)
217 + body: body
199 218 ))
200 219 } catch let error as ProviderError {
201 await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error)
220 + await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error, requestBody: requestPreview)
202 221 return .failure(error)
203 222 } catch {
204 223 let wrapped = ProviderError.networkError(underlying: error)
205 await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: wrapped)
224 + await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: wrapped, requestBody: requestPreview)
206 225 return .failure(wrapped)
207 226 }
208 227 }
@@ -213,18 +232,22 @@ struct ChatCompletionsRoute {
213 232 chat: ChatCompletionRequest,
214 233 resolution: RequestRouter.Resolution,
215 234 call: UpstreamCall,
216 localKey: APIKeyRecord?
235 + localKey: APIKeyRecord?,
236 + requestPreview: String
217 237 ) async -> AttemptOutcome {
218 238 // Pre-flight retries: transient failures before ANY byte reaches the
219 239 // client are retried/fallback-able. Open the upstream stream and pull
220 240 // its first event before committing to the client response.
241 + let started = Date()
221 242 var attempt = 1
222 243 while true {
223 244 do {
224 245 let (events, firstEvent) = try await openUpstreamStream(chat: chat, call: call, resolution: resolution)
225 246 return .success(streamResult(
226 247 chat: chat, resolution: resolution, call: call, localKey: localKey,
227 events: events, firstEvent: firstEvent
248 + events: events, firstEvent: firstEvent,
249 + started: started, ttfb: Date().timeIntervalSince(started),
250 + requestPreview: requestPreview
228 251 ))
229 252 } catch let error as ProviderError {
230 253 let retryable: Bool
@@ -272,15 +295,19 @@ struct ChatCompletionsRoute {
272 295 call: UpstreamCall,
273 296 localKey: APIKeyRecord?,
274 297 events: AsyncThrowingStream<SSEEvent, Error>.AsyncIterator,
275 firstEvent: SSEEvent?
298 + firstEvent: SSEEvent?,
299 + started: Date,
300 + ttfb: TimeInterval,
301 + requestPreview: String
276 302 ) -> RouteResult {
277 let started = Date()
278 303 return .stream(status: .ok, headers: []) { writer in
304 + await self.usageMeter.streamBegan()
305 + defer { Task { await self.usageMeter.streamEnded() } }
279 306 var iterator = events
280 307 var next = firstEvent
281 308 var usage: [String: Any]?
282 309 var finishReason: String?
283 var outputChars = 0
310 + var preview = ""
284 311
285 312 func iterate(_ handle: (SSEEvent) async throws -> Void) async throws {
286 313 while let event = next {
@@ -306,6 +333,7 @@ struct ChatCompletionsRoute {
306 333 cachedTokens: machine.cachedTokens > 0 ? machine.cachedTokens : nil
307 334 )
308 335 finishReason = machine.finishReasonSent
336 + preview = machine.textPreview
309 337
310 338 case .gemini:
311 339 var machine = GeminiTranslator.StreamMachine(
@@ -318,6 +346,7 @@ struct ChatCompletionsRoute {
318 346 for payload in machine.finalPayloads() { try await writer.send(raw: payload) }
319 347 usage = machine.lastUsage.map(GeminiTranslator.normalizedUsage)
320 348 finishReason = "stop"
349 + preview = machine.textPreview
321 350
322 351 case .compat:
323 352 try await iterate { event in
@@ -338,8 +367,9 @@ struct ChatCompletionsRoute {
338 367 for choice in choices {
339 368 if let finish = choice["finish_reason"] as? String { finishReason = finish }
340 369 if let delta = choice["delta"] as? [String: Any],
341 let content = delta["content"] as? String {
342 outputChars += content.count
370 + let content = delta["content"] as? String,
371 + preview.count < Self.previewLimit {
372 + preview += content
343 373 }
344 374 }
345 375 }
@@ -347,21 +377,23 @@ struct ChatCompletionsRoute {
347 377 }
348 378 // Client asked for usage but the upstream never sent it.
349 379 if chat.includeUsage, usage == nil {
350 let estimated = self.estimatedUsage(chat: chat, outputText: String(repeating: "x", count: outputChars))
380 + let estimated = self.estimatedUsage(chat: chat, outputText: preview)
351 381 usage = estimated
352 382 let emitter = ChunkEmitter(model: resolution.namespacedID)
353 383 try await writer.send(raw: emitter.usageChunk(estimated))
354 384 }
355 385 }
356 386 try await writer.sendDone()
387 + _ = finishReason
357 388 await self.meter(
358 usageDict: usage, finishReason: finishReason, resolution: resolution,
359 localKey: localKey, started: started, streamed: true, status: 200
389 + usageDict: usage, resolution: resolution,
390 + localKey: localKey, started: started, streamed: true, status: 200,
391 + ttfb: ttfb, requestBody: requestPreview, responseBody: preview
360 392 )
361 393 } catch let error as ProviderError {
362 394 // Mid-stream failure: never retry (bytes were forwarded).
363 395 // Emit a LiteLLM-style error frame, then terminate.
364 await self.meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: true, error: error)
396 + await self.meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: true, error: error, requestBody: requestPreview)
365 397 let wire = error.openAIWire
366 398 let frame = OpenAIError(error: .init(message: wire.message, type: wire.type, param: nil, code: wire.code))
367 399 try? await writer.send(raw: (try? JSONEncoder().encode(frame)) ?? Data())
@@ -375,7 +407,8 @@ struct ChatCompletionsRoute {
375 407 chat: ChatCompletionRequest,
376 408 resolution: RequestRouter.Resolution,
377 409 call: UpstreamCall,
378 localKey: APIKeyRecord?
410 + localKey: APIKeyRecord?,
411 + requestPreview: String
379 412 ) async -> AttemptOutcome {
380 413 let started = Date()
381 414 do {
@@ -426,10 +459,15 @@ struct ChatCompletionsRoute {
426 459 finishReason: finishReason,
427 460 usage: usage ?? estimatedUsage(chat: chat, outputText: content + reasoning)
428 461 )
429 await meter(response: response, resolution: resolution, localKey: localKey, started: started, streamed: false, status: 200)
430 return .success(.complete(status: .ok, headers: [("Content-Type", "application/json")], body: ChunkEmitter.serialize(response)))
462 + let responseData = ChunkEmitter.serialize(response)
463 + await meter(
464 + response: response, resolution: resolution, localKey: localKey,
465 + started: started, streamed: false, status: 200,
466 + requestBody: requestPreview, responseBody: Self.prettyJSON(responseData)
467 + )
468 + return .success(.complete(status: .ok, headers: [("Content-Type", "application/json")], body: responseData))
431 469 } catch let error as ProviderError {
432 await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error)
470 + await meterFailure(resolution: resolution, localKey: localKey, started: started, streamed: false, error: error, requestBody: requestPreview)
433 471 return .failure(error)
434 472 } catch {
435 473 return .failure(.networkError(underlying: error))
@@ -464,29 +502,48 @@ struct ChatCompletionsRoute {
464 502 private func meter(
465 503 response: [String: Any]? = nil,
466 504 usageDict: [String: Any]? = nil,
467 finishReason: String? = nil,
468 505 resolution: RequestRouter.Resolution,
469 506 localKey: APIKeyRecord?,
470 507 started: Date,
471 508 streamed: Bool,
472 status: Int
509 + status: Int,
510 + ttfb: TimeInterval? = nil,
511 + requestBody: String = "",
512 + responseBody: String = ""
473 513 ) async {
474 514 let usage = usageDict ?? response?["usage"] as? [String: Any] ?? [:]
475 515 let prompt = usage["prompt_tokens"] as? Int ?? 0
476 516 let completion = usage["completion_tokens"] as? Int ?? 0
477 517 let estimated = (usage["x_zyquo"] as? [String: Any])?["usage_estimated"] as? Bool ?? false
478 518 let reasoningTokens = (usage["completion_tokens_details"] as? [String: Any])?["reasoning_tokens"] as? Int
519 + let tokens = TokenUsage(inputTokens: prompt, outputTokens: completion, reasoningTokens: reasoningTokens)
520 + let cost = resolution.model.pricing?.cost(inputTokens: prompt, outputTokens: completion)
521 + let latency = Date().timeIntervalSince(started)
479 522 await usageMeter.record(UsageRecord(
480 523 namespacedModelID: resolution.namespacedID,
481 524 provider: resolution.model.provider,
482 525 localKeyName: localKey?.name,
483 usage: TokenUsage(inputTokens: prompt, outputTokens: completion, reasoningTokens: reasoningTokens),
526 + usage: tokens,
484 527 usageEstimated: estimated,
485 estimatedCost: resolution.model.pricing?.cost(inputTokens: prompt, outputTokens: completion),
486 latency: Date().timeIntervalSince(started),
528 + estimatedCost: cost,
529 + latency: latency,
487 530 streamed: streamed,
488 531 status: status
489 532 ))
533 + await requestLog.append(RequestLogEntry(
534 + namespacedModelID: resolution.namespacedID,
535 + provider: resolution.model.provider,
536 + status: status,
537 + streamed: streamed,
538 + latency: latency,
539 + upstreamTTFB: ttfb,
540 + usage: tokens,
541 + usageEstimated: estimated,
542 + estimatedCost: cost,
543 + localKeyName: localKey?.name,
544 + requestBody: requestBody,
545 + responseBody: responseBody
546 + ))
490 547 }
491 548
492 549 private func meterFailure(
@@ -494,15 +551,28 @@ struct ChatCompletionsRoute {
494 551 localKey: APIKeyRecord?,
495 552 started: Date,
496 553 streamed: Bool,
497 error: ProviderError
554 + error: ProviderError,
555 + requestBody: String = ""
498 556 ) async {
557 + let latency = Date().timeIntervalSince(started)
558 + let status = error.openAIWire.status
499 559 await usageMeter.record(UsageRecord(
500 560 namespacedModelID: resolution.namespacedID,
501 561 provider: resolution.model.provider,
502 562 localKeyName: localKey?.name,
503 latency: Date().timeIntervalSince(started),
563 + latency: latency,
504 564 streamed: streamed,
505 status: error.openAIWire.status
565 + status: status
566 + ))
567 + await requestLog.append(RequestLogEntry(
568 + namespacedModelID: resolution.namespacedID,
569 + provider: resolution.model.provider,
570 + status: status,
571 + streamed: streamed,
572 + latency: latency,
573 + localKeyName: localKey?.name,
574 + errorMessage: error.openAIWire.message,
575 + requestBody: requestBody
506 576 ))
507 577 }
508 578 }
modified Sources/ZyquoRouter/Server/Routes.swift +3 −1
@@ -35,7 +35,8 @@ struct Routes: Sendable {
35 35 providerKey: @escaping @Sendable (ProviderID) -> String? = { provider in
36 36 try? SecureKeyStore().key(for: provider)
37 37 },
38 usageMeter: UsageMeter = UsageMeter()
38 + usageMeter: UsageMeter = UsageMeter(),
39 + requestLog: RequestLogStore = RequestLogStore()
39 40 ) {
40 41 self.router = router
41 42 self.auth = auth
@@ -46,6 +47,7 @@ struct Routes: Sendable {
46 47 router: router,
47 48 providerKey: providerKey,
48 49 usageMeter: usageMeter,
50 + requestLog: requestLog,
49 51 retryPolicy: RetryPolicy()
50 52 )
51 53 }
added Sources/ZyquoRouter/Services/RequestLogStore.swift +120 −0
@@ -0,0 +1,120 @@
1 +//
2 +// RequestLogStore.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Live traffic log: a bounded in-memory ring of RequestLogEntry. Bodies are
9 +// carried in memory but the UI redacts them by default (explicit per-session
10 +// reveal). Never persisted to disk unless the user exports; provider keys
11 +// never enter an entry.
12 +//
13 +
14 +import Foundation
15 +
16 +/// One routed request as shown in the Requests screen.
17 +struct RequestLogEntry: Identifiable, Sendable {
18 + let id: UUID
19 + var date: Date
20 + var method: String
21 + var path: String
22 + var namespacedModelID: String
23 + var provider: ProviderID
24 + var status: Int
25 + var streamed: Bool
26 + var latency: TimeInterval
27 + /// Time to first upstream byte (streaming) — nil for buffered calls.
28 + var upstreamTTFB: TimeInterval?
29 + var usage: TokenUsage
30 + var usageEstimated: Bool
31 + var estimatedCost: Double?
32 + var localKeyName: String?
33 + var errorMessage: String?
34 + /// Pretty-printed client request JSON (redacted in UI by default).
35 + var requestBody: String
36 + /// Pretty-printed response JSON, or the accumulated stream preview.
37 + var responseBody: String
38 +
39 + init(
40 + id: UUID = UUID(),
41 + date: Date = Date(),
42 + method: String = "POST",
43 + path: String = "/v1/chat/completions",
44 + namespacedModelID: String,
45 + provider: ProviderID,
46 + status: Int,
47 + streamed: Bool,
48 + latency: TimeInterval,
49 + upstreamTTFB: TimeInterval? = nil,
50 + usage: TokenUsage = TokenUsage(),
51 + usageEstimated: Bool = false,
52 + estimatedCost: Double? = nil,
53 + localKeyName: String? = nil,
54 + errorMessage: String? = nil,
55 + requestBody: String = "",
56 + responseBody: String = ""
57 + ) {
58 + self.id = id
59 + self.date = date
60 + self.method = method
61 + self.path = path
62 + self.namespacedModelID = namespacedModelID
63 + self.provider = provider
64 + self.status = status
65 + self.streamed = streamed
66 + self.latency = latency
67 + self.upstreamTTFB = upstreamTTFB
68 + self.usage = usage
69 + self.usageEstimated = usageEstimated
70 + self.estimatedCost = estimatedCost
71 + self.localKeyName = localKeyName
72 + self.errorMessage = errorMessage
73 + self.requestBody = requestBody
74 + self.responseBody = responseBody
75 + }
76 +}
77 +
78 +actor RequestLogStore {
79 + /// Ring capacity — old entries fall off the front.
80 + static let capacity = 500
81 +
82 + private(set) var entries: [RequestLogEntry] = []
83 + /// Monotonic revision so the UI can cheaply detect changes.
84 + private(set) var revision = 0
85 +
86 + func append(_ entry: RequestLogEntry) {
87 + entries.append(entry)
88 + if entries.count > Self.capacity {
89 + entries.removeFirst(entries.count - Self.capacity)
90 + }
91 + revision += 1
92 + }
93 +
94 + func clear() {
95 + entries.removeAll()
96 + revision += 1
97 + }
98 +
99 + /// Metadata-only JSON export (bodies excluded by design).
100 + func exportJSON() -> Data {
101 + let rows = entries.map { entry -> [String: Any] in
102 + [
103 + "date": ISO8601DateFormatter().string(from: entry.date),
104 + "model": entry.namespacedModelID,
105 + "provider": entry.provider.rawValue,
106 + "status": entry.status,
107 + "streamed": entry.streamed,
108 + "latency_ms": Int(entry.latency * 1000),
109 + "ttfb_ms": entry.upstreamTTFB.map { Int($0 * 1000) } as Any,
110 + "input_tokens": entry.usage.inputTokens,
111 + "output_tokens": entry.usage.outputTokens,
112 + "usage_estimated": entry.usageEstimated,
113 + "cost_usd": entry.estimatedCost as Any,
114 + "key": entry.localKeyName as Any,
115 + "error": entry.errorMessage as Any,
116 + ]
117 + }
118 + return (try? JSONSerialization.data(withJSONObject: rows, options: [.prettyPrinted, .sortedKeys])) ?? Data("[]".utf8)
119 + }
120 +}
modified Sources/ZyquoRouter/Translate/AnthropicTranslator.swift +3 −0
@@ -272,6 +272,8 @@ enum AnthropicTranslator {
272 272
273 273 private var toolIndex = -1
274 274 private var currentBlockIsTool = false
275 + /// Accumulated assistant text for the request-log inspector (capped).
276 + private(set) var textPreview = ""
275 277 private(set) var promptTokens = 0
276 278 private(set) var cachedTokens = 0
277 279 private(set) var completionTokens = 0
@@ -318,6 +320,7 @@ enum AnthropicTranslator {
318 320 switch delta["type"] as? String {
319 321 case "text_delta":
320 322 let text = delta["text"] as? String ?? ""
323 + if textPreview.count < 20_000 { textPreview += text }
321 324 return (text.isEmpty ? [] : [emitter.contentChunk(text)], false)
322 325 case "input_json_delta":
323 326 let fragment = delta["partial_json"] as? String ?? ""
modified Sources/ZyquoRouter/Translate/GeminiTranslator.swift +3 −0
@@ -295,6 +295,8 @@ enum GeminiTranslator {
295 295 private var roleSent = false
296 296 private var toolIndex = -1
297 297 private var finishSent = false
298 + /// Accumulated assistant text for the request-log inspector (capped).
299 + private(set) var textPreview = ""
298 300 private(set) var lastUsage: [String: Any]?
299 301 private(set) var promptTokens = 0
300 302 private(set) var completionTokens = 0
@@ -328,6 +330,7 @@ enum GeminiTranslator {
328 330 if part["thought"] as? Bool == true {
329 331 payloads.append(emitter.reasoningChunk(text))
330 332 } else {
333 + if textPreview.count < 20_000 { textPreview += text }
331 334 payloads.append(emitter.contentChunk(text))
332 335 }
333 336 }
added Sources/ZyquoRouter/ViewModels/RouterConfigStore.swift +94 −0
@@ -0,0 +1,94 @@
1 +//
2 +// RouterConfigStore.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// User routing configuration: aliases (fast → cerebras/…), fallback chains,
9 +// disabled models, favorites. Persisted to router-config.json; the server
10 +// takes a snapshot at Start (restart applies changes to a running server).
11 +//
12 +
13 +import Foundation
14 +
15 +struct RouterConfig: Codable {
16 + static let fileName = "router-config.json"
17 +
18 + var aliases: [String: String] = [:]
19 + var fallbackChains: [String: [String]] = [:]
20 + var disabledIDs: Set<String> = []
21 + var favoriteIDs: Set<String> = []
22 +}
23 +
24 +@MainActor
25 +final class RouterConfigStore: ObservableObject {
26 + static var fileName: String { RouterConfig.fileName }
27 +
28 + @Published var config: RouterConfig {
29 + didSet { persistence.save(config, to: Self.fileName) }
30 + }
31 +
32 + private let persistence: PersistenceService
33 +
34 + init(persistence: PersistenceService = .shared) {
35 + self.persistence = persistence
36 + config = persistence.load(RouterConfig.self, from: Self.fileName) ?? RouterConfig()
37 + }
38 +
39 + /// Snapshot for the server thread (also used headless via `load`).
40 + static func snapshot() -> RouterConfig {
41 + PersistenceService.shared.load(RouterConfig.self, from: fileName) ?? RouterConfig()
42 + }
43 +
44 + func isDisabled(_ namespacedID: String) -> Bool {
45 + config.disabledIDs.contains(namespacedID)
46 + }
47 +
48 + func setDisabled(_ disabled: Bool, for namespacedID: String) {
49 + if disabled {
50 + config.disabledIDs.insert(namespacedID)
51 + } else {
52 + config.disabledIDs.remove(namespacedID)
53 + }
54 + }
55 +
56 + func isFavorite(_ namespacedID: String) -> Bool {
57 + config.favoriteIDs.contains(namespacedID)
58 + }
59 +
60 + func toggleFavorite(_ namespacedID: String) {
61 + if !config.favoriteIDs.insert(namespacedID).inserted {
62 + config.favoriteIDs.remove(namespacedID)
63 + }
64 + }
65 +
66 + func setAlias(_ alias: String, target: String) {
67 + let name = alias.trimmingCharacters(in: .whitespaces)
68 + guard !name.isEmpty else { return }
69 + config.aliases[name] = target
70 + }
71 +
72 + func removeAlias(_ alias: String) {
73 + config.aliases.removeValue(forKey: alias)
74 + }
75 +
76 + func setChain(for namespacedID: String, chain: [String]) {
77 + if chain.isEmpty {
78 + config.fallbackChains.removeValue(forKey: namespacedID)
79 + } else {
80 + config.fallbackChains[namespacedID] = chain
81 + }
82 + }
83 +
84 + /// Export for Settings → Advanced (never includes keys).
85 + func exportData() throws -> Data {
86 + let encoder = JSONEncoder()
87 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
88 + return try encoder.encode(config)
89 + }
90 +
91 + func importData(_ data: Data) throws {
92 + config = try JSONDecoder().decode(RouterConfig.self, from: data)
93 + }
94 +}
modified Sources/ZyquoRouter/ViewModels/ServerController.swift +10 −2
@@ -29,6 +29,8 @@ final class ServerController: ObservableObject {
29 29
30 30 /// Shared with Routes at Start; the dashboard reads totals from it.
31 31 let usageMeter = UsageMeter()
32 + /// Live traffic log shared with the Requests screen.
33 + let requestLog = RequestLogStore()
32 34 /// When the current run started (uptime tile).
33 35 @Published private(set) var startedAt: Date?
34 36
@@ -57,10 +59,16 @@ final class ServerController: ObservableObject {
57 59 return
58 60 }
59 61
62 + let routerConfig = RouterConfigStore.snapshot()
60 63 let routes = Routes(
61 router: RequestRouter(),
64 + router: RequestRouter(
65 + aliases: routerConfig.aliases,
66 + disabledIDs: routerConfig.disabledIDs,
67 + fallbackChains: routerConfig.fallbackChains
68 + ),
62 69 auth: AuthMiddleware(keys: localKeys),
63 usageMeter: usageMeter
70 + usageMeter: usageMeter,
71 + requestLog: requestLog
64 72 )
65 73 let server = HTTPServer(host: host, port: port) { request in
66 74 await routes.handle(request)
added Sources/ZyquoRouter/Views/CommandPalette.swift +165 −0
@@ -0,0 +1,165 @@
1 +//
2 +// CommandPalette.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// ⌘K: fuzzy access to everything — start/stop, sections, copy endpoint,
9 +// and copy any model ID. Return runs the highlighted action.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct CommandPalette: View {
15 + @Binding var isPresented: Bool
16 + @EnvironmentObject private var server: ServerController
17 + @EnvironmentObject private var catalog: ModelCatalog
18 +
19 + @State private var query = ""
20 + @State private var highlighted = 0
21 + @FocusState private var fieldFocused: Bool
22 +
23 + private struct Action: Identifiable {
24 + let id: String
25 + let title: String
26 + let subtitle: String?
27 + let systemImage: String
28 + let run: () -> Void
29 + }
30 +
31 + private var actions: [Action] {
32 + var all: [Action] = [
33 + Action(
34 + id: "server",
35 + title: server.isRunning ? "Stop Server" : "Start Server",
36 + subtitle: server.isRunning ? "Port \(server.port)" : nil,
37 + systemImage: "power",
38 + run: { server.toggle() }
39 + ),
40 + Action(
41 + id: "copy-endpoint",
42 + title: "Copy Endpoint URL",
43 + subtitle: server.endpointURL,
44 + systemImage: "doc.on.doc",
45 + run: {
46 + NSPasteboard.general.clearContents()
47 + NSPasteboard.general.setString(server.endpointURL, forType: .string)
48 + }
49 + ),
50 + ]
51 + for section in AppSection.allCases {
52 + all.append(Action(
53 + id: "go-\(section.rawValue)",
54 + title: "Go to \(section.rawValue)",
55 + subtitle: nil,
56 + systemImage: section.systemImage,
57 + run: { UserDefaults.standard.set(section.rawValue, forKey: "selectedSection") }
58 + ))
59 + }
60 + for model in catalog.all {
61 + let id = RequestRouter.namespacedID(for: model)
62 + all.append(Action(
63 + id: "model-\(id)",
64 + title: id,
65 + subtitle: "Copy model ID",
66 + systemImage: "square.grid.2x2",
67 + run: {
68 + NSPasteboard.general.clearContents()
69 + NSPasteboard.general.setString(id, forType: .string)
70 + }
71 + ))
72 + }
73 + return all
74 + }
75 +
76 + private var filtered: [Action] {
77 + let trimmed = query.trimmingCharacters(in: .whitespaces)
78 + guard !trimmed.isEmpty else {
79 + return Array(actions.prefix(9))
80 + }
81 + return Array(actions.filter {
82 + $0.title.localizedCaseInsensitiveContains(trimmed)
83 + }.prefix(9))
84 + }
85 +
86 + var body: some View {
87 + VStack(spacing: 0) {
88 + TextField("Type a command or model…", text: $query)
89 + .textFieldStyle(.plain)
90 + .font(ZyquoFont.body(size: 15))
91 + .padding(ZyquoSpacing.md)
92 + .focused($fieldFocused)
93 + .onSubmit { runHighlighted() }
94 + .onChange(of: query) { _ in highlighted = 0 }
95 +
96 + ZyquoHairline()
97 +
98 + VStack(spacing: 0) {
99 + ForEach(Array(filtered.enumerated()), id: \.element.id) { index, action in
100 + HStack(spacing: ZyquoSpacing.sm) {
101 + Image(systemName: action.systemImage)
102 + .font(.system(size: 12))
103 + .foregroundStyle(index == highlighted ? ZyquoColor.accent : ZyquoColor.textSecondary)
104 + .frame(width: 18)
105 + Text(action.title)
106 + .font(action.id.hasPrefix("model-") ? ZyquoFont.mono(size: 12.5) : ZyquoFont.body())
107 + .foregroundStyle(ZyquoColor.textPrimary)
108 + .lineLimit(1)
109 + .truncationMode(.middle)
110 + Spacer()
111 + if let subtitle = action.subtitle {
112 + Text(subtitle)
113 + .font(ZyquoFont.caption)
114 + .foregroundStyle(ZyquoColor.textTertiary)
115 + .lineLimit(1)
116 + }
117 + }
118 + .padding(.horizontal, ZyquoSpacing.md)
119 + .padding(.vertical, ZyquoSpacing.xs)
120 + .background(index == highlighted ? ZyquoColor.accentSubtle : .clear)
121 + .contentShape(Rectangle())
122 + .onTapGesture {
123 + action.run()
124 + isPresented = false
125 + }
126 + .onHover { hover in
127 + if hover { highlighted = index }
128 + }
129 + }
130 + }
131 + .padding(.vertical, ZyquoSpacing.xxs)
132 + }
133 + .frame(width: 520)
134 + .background(
135 + RoundedRectangle(cornerRadius: ZyquoRadius.large)
136 + .fill(ZyquoColor.surface)
137 + .overlay(
138 + RoundedRectangle(cornerRadius: ZyquoRadius.large)
139 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
140 + )
141 + )
142 + .zyquoSoftShadow()
143 + .onAppear { fieldFocused = true }
144 + .background(paletteKeyHandlers)
145 + }
146 +
147 + /// Hidden buttons carrying the arrow-key/escape shortcuts.
148 + private var paletteKeyHandlers: some View {
149 + Group {
150 + Button("") { highlighted = min(highlighted + 1, filtered.count - 1) }
151 + .keyboardShortcut(.downArrow, modifiers: [])
152 + Button("") { highlighted = max(highlighted - 1, 0) }
153 + .keyboardShortcut(.upArrow, modifiers: [])
154 + Button("") { isPresented = false }
155 + .keyboardShortcut(.escape, modifiers: [])
156 + }
157 + .hidden()
158 + }
159 +
160 + private func runHighlighted() {
161 + guard filtered.indices.contains(highlighted) else { return }
162 + filtered[highlighted].run()
163 + isPresented = false
164 + }
165 +}
modified Sources/ZyquoRouter/Views/DashboardView.swift +113 −1
@@ -18,6 +18,9 @@ struct DashboardView: View {
18 18
19 19 @State private var totals: (requests: Int, tokens: Int, cost: Double, errors: Int) = (0, 0, 0, 0)
20 20 @State private var uptimeText = "—"
21 + @State private var sparkline: [Int] = []
22 + @State private var activeStreams = 0
23 + @State private var breakdown: [(provider: ProviderID, count: Int)] = []
21 24 private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
22 25
23 26 private var hasAnyProviderKey: Bool {
@@ -55,8 +58,15 @@ struct DashboardView: View {
55 58 value: "\(totals.errors)",
56 59 valueColor: totals.errors > 0 ? ZyquoColor.danger : ZyquoColor.textPrimary
57 60 )
61 + StatTile(label: "Active streams", value: "\(activeStreams)",
62 + valueColor: activeStreams > 0 ? ZyquoColor.accent : ZyquoColor.textPrimary)
58 63 StatTile(label: "Uptime", value: uptimeText)
59 64 }
65 +
66 + HStack(alignment: .top, spacing: ZyquoSpacing.sm) {
67 + SparklineCard(title: "Requests / min (last 15 min)", values: sparkline)
68 + ProviderBreakdownCard(breakdown: breakdown, total: totals.requests)
69 + }
60 70 }
61 71 .padding(ZyquoMetrics.contentInset)
62 72 .frame(maxWidth: 860, alignment: .leading)
@@ -80,8 +90,14 @@ struct DashboardView: View {
80 90 Task {
81 91 let cutoff = Calendar.current.startOfDay(for: Date())
82 92 let today = await meter.totals(since: cutoff)
93 + let perMinute = await meter.requestsPerMinute(minutes: 15)
94 + let streams = await meter.activeStreams
95 + let providers = await meter.providerBreakdown(since: cutoff)
83 96 withAnimation(ZyquoMotion.live) {
84 97 totals = (today.requests, today.usage.totalTokens, today.cost, today.errors)
98 + sparkline = perMinute
99 + activeStreams = streams
100 + breakdown = providers
85 101 }
86 102 }
87 103 }
@@ -230,7 +246,6 @@ private struct ServerCard: View {
230 246 )
231 247 }
232 248 .buttonStyle(.plain)
233 .keyboardShortcut("r", modifiers: .command)
234 249 }
235 250
236 251 private var statusColor: Color {
@@ -357,3 +372,100 @@ private struct OnboardingStep: View {
357 372 }
358 373 }
359 374 }
375 +
376 +// MARK: - Live charts
377 +
378 +/// Cyan requests-per-minute sparkline (chart token: requests = cyan).
379 +private struct SparklineCard: View {
380 + let title: String
381 + let values: [Int]
382 +
383 + var body: some View {
384 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
385 + Text(title.uppercased())
386 + .font(.system(size: 9.5, weight: .semibold))
387 + .foregroundStyle(ZyquoColor.textTertiary)
388 + .kerning(0.4)
389 + GeometryReader { proxy in
390 + let peak = max(values.max() ?? 1, 1)
391 + let barWidth = proxy.size.width / CGFloat(max(values.count, 1))
392 + HStack(alignment: .bottom, spacing: 2) {
393 + ForEach(Array(values.enumerated()), id: \.offset) { _, value in
394 + RoundedRectangle(cornerRadius: 1.5)
395 + .fill(value == 0 ? AnyShapeStyle(ZyquoColor.border) : AnyShapeStyle(ZyquoColor.chartRequests))
396 + .frame(
397 + width: max(barWidth - 2, 2),
398 + height: value == 0 ? 2 : max(proxy.size.height * CGFloat(value) / CGFloat(peak), 3)
399 + )
400 + }
401 + }
402 + .frame(maxHeight: .infinity, alignment: .bottom)
403 + }
404 + .frame(height: 56)
405 + }
406 + .padding(ZyquoSpacing.md)
407 + .frame(maxWidth: .infinity, alignment: .leading)
408 + .zyquoCard()
409 + .animation(ZyquoMotion.live, value: values)
410 + }
411 +}
412 +
413 +/// Per-provider share of today's requests.
414 +private struct ProviderBreakdownCard: View {
415 + let breakdown: [(provider: ProviderID, count: Int)]
416 + let total: Int
417 +
418 + var body: some View {
419 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
420 + Text("BY PROVIDER (TODAY)")
421 + .font(.system(size: 9.5, weight: .semibold))
422 + .foregroundStyle(ZyquoColor.textTertiary)
423 + .kerning(0.4)
424 + if total == 0 {
425 + Text("No requests yet today.")
426 + .font(ZyquoFont.caption)
427 + .foregroundStyle(ZyquoColor.textTertiary)
428 + .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading)
429 + } else {
430 + GeometryReader { proxy in
431 + HStack(spacing: 1) {
432 + ForEach(breakdown, id: \.provider) { slice in
433 + RoundedRectangle(cornerRadius: 2)
434 + .fill(ZyquoColor.providerHue(slice.provider))
435 + .frame(width: max(proxy.size.width * CGFloat(slice.count) / CGFloat(total), 3))
436 + }
437 + }
438 + }
439 + .frame(height: 10)
440 + FlowLegend(breakdown: breakdown)
441 + }
442 + }
443 + .padding(ZyquoSpacing.md)
444 + .frame(maxWidth: .infinity, alignment: .leading)
445 + .zyquoCard()
446 + }
447 +}
448 +
449 +private struct FlowLegend: View {
450 + let breakdown: [(provider: ProviderID, count: Int)]
451 +
452 + var body: some View {
453 + HStack(spacing: ZyquoSpacing.sm) {
454 + ForEach(breakdown.prefix(5), id: \.provider) { slice in
455 + HStack(spacing: 4) {
456 + Circle()
457 + .fill(ZyquoColor.providerHue(slice.provider))
458 + .frame(width: 6, height: 6)
459 + Text("\(slice.provider.displayName) \(slice.count)")
460 + .font(ZyquoFont.caption)
461 + .foregroundStyle(ZyquoColor.textSecondary)
462 + }
463 + }
464 + if breakdown.count > 5 {
465 + Text("+\(breakdown.count - 5) more")
466 + .font(ZyquoFont.caption)
467 + .foregroundStyle(ZyquoColor.textTertiary)
468 + }
469 + }
470 + }
471 +}
modified Sources/ZyquoRouter/Views/MainWindowView.swift +16 −0
@@ -23,6 +23,8 @@ struct MainWindowView: View {
23 23 )
24 24 }
25 25
26 + @State private var paletteShown = false
27 +
26 28 var body: some View {
27 29 HStack(spacing: 0) {
28 30 Navigator(section: section)
@@ -38,6 +40,20 @@ struct MainWindowView: View {
38 40 minWidth: ZyquoMetrics.windowMinWidth,
39 41 minHeight: ZyquoMetrics.windowMinHeight
40 42 )
43 + .overlay(alignment: .top) {
44 + if paletteShown {
45 + CommandPalette(isPresented: $paletteShown)
46 + .padding(.top, 90)
47 + .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .top)))
48 + }
49 + }
50 + .background(
51 + Button("") {
52 + withAnimation(ZyquoMotion.state) { paletteShown.toggle() }
53 + }
54 + .keyboardShortcut("k", modifiers: .command)
55 + .hidden()
56 + )
41 57 .preferredColorScheme(appearance.themeMode.colorScheme)
42 58 }
43 59
modified Sources/ZyquoRouter/Views/ModelsView.swift +239 −15
@@ -6,25 +6,38 @@
6 6 // Mail: contact@spboucher.ai
7 7 //
8 8 // The full namespaced catalog: search, provider filter, capability badges,
9 // context + pricing columns, one-click ID copy. Aliases and fallback-chain
10 // editors join in Phase 6.
9 +// context + pricing columns, one-click ID copy, enable/disable (disabled
10 +// models 404 through the API), favorites, plus the aliases and fallback-
11 +// chain editors. Router config changes apply at the next server Start.
11 12 //
12 13
13 14 import SwiftUI
14 15
15 16 struct ModelsView: View {
16 17 @EnvironmentObject private var catalog: ModelCatalog
18 + @EnvironmentObject private var routerConfig: RouterConfigStore
19 + @EnvironmentObject private var server: ServerController
20 +
17 21 @State private var search = ""
18 22 @State private var providerFilter: ProviderID?
23 + @State private var showAliases = false
24 + @State private var chainModel: String?
19 25
20 26 private var filtered: [AIModel] {
21 catalog.all.filter { model in
22 if let providerFilter, model.provider != providerFilter { return false }
23 guard !search.isEmpty else { return true }
24 let namespaced = RequestRouter.namespacedID(for: model)
25 return namespaced.localizedCaseInsensitiveContains(search)
26 || model.displayName.localizedCaseInsensitiveContains(search)
27 }
27 + catalog.all
28 + .filter { model in
29 + 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 in
36 + 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 otherwise
40 + }
28 41 }
29 42
30 43 var body: some View {
@@ -35,6 +48,7 @@ struct ModelsView: View {
35 48 subtitle: "\(catalog.all.count) models across \(ProviderID.builtIn.count) providers"
36 49 )
37 50 Spacer()
51 + Button("Aliases…") { showAliases = true }
38 52 Picker("", selection: $providerFilter) {
39 53 Text("All providers").tag(ProviderID?.none)
40 54 ForEach(ProviderID.builtIn) { provider in
@@ -42,41 +56,85 @@ struct ModelsView: View {
42 56 }
43 57 }
44 58 .labelsHidden()
45 .frame(width: 170)
59 + .frame(width: 160)
46 60 TextField("Search models…", text: $search)
47 61 .textFieldStyle(.roundedBorder)
48 .frame(width: 220)
62 + .frame(width: 200)
49 63 }
50 64 .padding(ZyquoMetrics.contentInset)
51 65
66 + 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 + }
73 +
52 74 ZyquoHairline()
53 75
54 76 ScrollView {
55 77 LazyVStack(spacing: 0) {
56 78 ForEach(filtered) { model in
57 ModelRow(model: model)
79 + ModelRow(model: model, editChain: { chainModel = $0 })
58 80 ZyquoHairline()
59 81 .padding(.leading, ZyquoMetrics.contentInset)
60 82 }
61 83 }
62 84 }
63 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 in
93 + ChainEditor(namespacedID: target.id)
94 + }
95 + }
96 +
97 + /// Rough signal that in-memory config differs from what the server took.
98 + private var configDiffers: Bool {
99 + !routerConfig.config.disabledIDs.isEmpty
100 + || !routerConfig.config.aliases.isEmpty
101 + || !routerConfig.config.fallbackChains.isEmpty
64 102 }
65 103 }
66 104
105 +private struct ChainTarget: Identifiable {
106 + let id: String
107 +}
108 +
109 +// MARK: - Row
110 +
67 111 private struct ModelRow: View {
68 112 let model: AIModel
113 + let editChain: (String) -> Void
114 + @EnvironmentObject private var routerConfig: RouterConfigStore
69 115 @State private var hovering = false
70 116
71 117 private var namespacedID: String { RequestRouter.namespacedID(for: model) }
118 + private var disabled: Bool { routerConfig.isDisabled(namespacedID) }
72 119
73 120 var body: some View {
74 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)
131 +
75 132 VStack(alignment: .leading, spacing: 3) {
76 133 HStack(spacing: ZyquoSpacing.xs) {
77 134 Text(namespacedID)
78 135 .font(ZyquoFont.mono(size: 12.5, weight: .medium))
79 .foregroundStyle(ZyquoColor.textPrimary)
136 + .foregroundStyle(disabled ? ZyquoColor.textTertiary : ZyquoColor.textPrimary)
137 + .strikethrough(disabled, color: ZyquoColor.textTertiary)
80 138 .lineLimit(1)
81 139 .truncationMode(.middle)
82 140 if hovering {
@@ -88,6 +146,9 @@ private struct ModelRow: View {
88 146 if model.isLegacy {
89 147 CapabilityBadge(label: "LEGACY", tint: ZyquoColor.textTertiary)
90 148 }
149 + if let chain = routerConfig.config.fallbackChains[namespacedID], !chain.isEmpty {
150 + CapabilityBadge(label: "CHAIN +\(chain.count)", tint: ZyquoColor.graphite)
151 + }
91 152 }
92 153 HStack(spacing: ZyquoSpacing.xs) {
93 154 ProviderBadge(provider: model.provider)
@@ -99,6 +160,11 @@ private struct ModelRow: View {
99 160
100 161 Spacer()
101 162
163 + if hovering {
164 + Button("Fallbacks…") { editChain(namespacedID) }
165 + .font(ZyquoFont.caption)
166 + }
167 +
102 168 HStack(spacing: ZyquoSpacing.xxs) {
103 169 if model.capabilities.vision { CapabilityBadge(label: "VISION") }
104 170 if model.capabilities.tools { CapabilityBadge(label: "TOOLS") }
@@ -109,12 +175,21 @@ private struct ModelRow: View {
109 175 Text(model.contextBadge)
110 176 .font(ZyquoFont.mono(size: 11))
111 177 .foregroundStyle(ZyquoColor.textSecondary)
112 .frame(width: 66, alignment: .trailing)
178 + .frame(width: 62, alignment: .trailing)
113 179
114 180 Text(priceText)
115 181 .font(ZyquoFont.mono(size: 11))
116 182 .foregroundStyle(ZyquoColor.textSecondary)
117 .frame(width: 120, alignment: .trailing)
183 + .frame(width: 112, alignment: .trailing)
184 +
185 + 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")
118 193 }
119 194 .padding(.horizontal, ZyquoMetrics.contentInset)
120 195 .padding(.vertical, ZyquoSpacing.xs)
@@ -129,3 +204,152 @@ private struct ModelRow: View {
129 204 return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok)
130 205 }
131 206 }
207 +
208 +// MARK: - Alias editor
209 +
210 +private struct AliasEditor: View {
211 + @EnvironmentObject private var routerConfig: RouterConfigStore
212 + @EnvironmentObject private var catalog: ModelCatalog
213 + @Environment(\.dismiss) private var dismiss
214 +
215 + @State private var newAlias = ""
216 + @State private var newTarget = ""
217 +
218 + 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)
225 +
226 + 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 in
236 + 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 + }
248 +
249 + 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 in
257 + 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 + }
274 +
275 + HStack {
276 + Spacer()
277 + Button("Done") { dismiss() }
278 + .keyboardShortcut(.defaultAction)
279 + }
280 + }
281 + .padding(ZyquoSpacing.xl)
282 + .frame(width: 560)
283 + }
284 +}
285 +
286 +// MARK: - Fallback-chain editor
287 +
288 +private struct ChainEditor: View {
289 + let namespacedID: String
290 + @EnvironmentObject private var routerConfig: RouterConfigStore
291 + @EnvironmentObject private var catalog: ModelCatalog
292 + @Environment(\.dismiss) private var dismiss
293 +
294 + @State private var chain: [String] = []
295 + @State private var addition = ""
296 +
297 + 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)
304 +
305 + List {
306 + ForEach(chain, id: \.self) { model in
307 + Text(model)
308 + .font(ZyquoFont.mono(size: 12))
309 + }
310 + .onMove { indices, offset in
311 + chain.move(fromOffsets: indices, toOffset: offset)
312 + }
313 + .onDelete { indices in
314 + chain.remove(atOffsets: indices)
315 + }
316 + }
317 + .frame(height: 160)
318 +
319 + HStack(spacing: ZyquoSpacing.xs) {
320 + Picker("", selection: $addition) {
321 + Text("Add a fallback…").tag("")
322 + ForEach(catalog.all) { model in
323 + 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 + }
337 +
338 + 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 +}
modified Sources/ZyquoRouter/Views/PlaygroundView.swift +93 −15
@@ -22,6 +22,12 @@ struct PlaygroundView: View {
22 22 @State private var output = ""
23 23 @State private var running = false
24 24 @State private var errorText: String?
25 + @State private var requestJSON = ""
26 + @State private var rawResponse = ""
27 + @State private var temperature: Double = 1.0
28 + @State private var useTemperature = false
29 + @State private var maxTokens = ""
30 + @State private var reasoningEffort = "off"
25 31
26 32 var body: some View {
27 33 VStack(alignment: .leading, spacing: 0) {
@@ -66,6 +72,42 @@ struct PlaygroundView: View {
66 72 .disabled(modelID.isEmpty || prompt.isEmpty && !running)
67 73 }
68 74
75 + // Parameters
76 + HStack(spacing: ZyquoSpacing.md) {
77 + Toggle("temperature", isOn: $useTemperature)
78 + .toggleStyle(.checkbox)
79 + .font(ZyquoFont.mono(size: 11))
80 + if useTemperature {
81 + Slider(value: $temperature, in: 0...2, step: 0.1)
82 + .frame(width: 120)
83 + Text(String(format: "%.1f", temperature))
84 + .font(ZyquoFont.mono(size: 11))
85 + .foregroundStyle(ZyquoColor.textSecondary)
86 + }
87 + HStack(spacing: 4) {
88 + Text("max_tokens")
89 + .font(ZyquoFont.mono(size: 11))
90 + .foregroundStyle(ZyquoColor.textSecondary)
91 + TextField("auto", text: $maxTokens)
92 + .textFieldStyle(.roundedBorder)
93 + .font(ZyquoFont.mono(size: 11))
94 + .frame(width: 64)
95 + }
96 + HStack(spacing: 4) {
97 + Text("reasoning")
98 + .font(ZyquoFont.mono(size: 11))
99 + .foregroundStyle(ZyquoColor.textSecondary)
100 + Picker("", selection: $reasoningEffort) {
101 + ForEach(["off", "low", "medium", "high"], id: \.self) { level in
102 + Text(level).tag(level)
103 + }
104 + }
105 + .labelsHidden()
106 + .frame(width: 92)
107 + }
108 + Spacer()
109 + }
110 +
69 111 TextEditor(text: $prompt)
70 112 .font(ZyquoFont.mono(size: 12.5))
71 113 .scrollContentBackground(.hidden)
@@ -82,15 +124,43 @@ struct PlaygroundView: View {
82 124 .foregroundStyle(ZyquoColor.danger)
83 125 }
84 126
127 + HSplitView {
128 + pane(title: "RESPONSE", text: output, placeholder: "Response appears here.")
129 + VStack(spacing: ZyquoSpacing.xs) {
130 + pane(title: "REQUEST JSON", text: requestJSON, placeholder: "The exact JSON sent to the router.")
131 + pane(title: "RAW RESPONSE", text: rawResponse, placeholder: streaming ? "Raw SSE chunks." : "Raw response JSON.")
132 + }
133 + .frame(minWidth: 260)
134 + }
135 + .frame(maxHeight: .infinity)
136 + }
137 + .padding(ZyquoMetrics.contentInset)
138 + .onAppear {
139 + if modelID.isEmpty, let first = catalog.defaultModel {
140 + modelID = RequestRouter.namespacedID(for: first)
141 + }
142 + }
143 + }
144 +
145 + private func pane(title: String, text: String, placeholder: String) -> some View {
146 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
147 + HStack {
148 + Text(title)
149 + .font(.system(size: 9.5, weight: .semibold))
150 + .foregroundStyle(ZyquoColor.textTertiary)
151 + .kerning(0.4)
152 + Spacer()
153 + if !text.isEmpty { CopyButton(value: text) }
154 + }
85 155 ScrollView {
86 Text(output.isEmpty ? "Response appears here." : output)
87 .font(ZyquoFont.mono(size: 12.5))
88 .foregroundStyle(output.isEmpty ? ZyquoColor.textTertiary : ZyquoColor.textPrimary)
156 + Text(text.isEmpty ? placeholder : text)
157 + .font(ZyquoFont.mono(size: 11.5))
158 + .foregroundStyle(text.isEmpty ? ZyquoColor.textTertiary : ZyquoColor.textPrimary)
89 159 .textSelection(.enabled)
90 160 .frame(maxWidth: .infinity, alignment: .leading)
91 .padding(ZyquoSpacing.sm)
161 + .padding(ZyquoSpacing.xs)
92 162 }
93 .frame(maxHeight: .infinity)
163 + .frame(maxWidth: .infinity, maxHeight: .infinity)
94 164 .background(
95 165 RoundedRectangle(cornerRadius: ZyquoRadius.small)
96 166 .fill(ZyquoColor.surface)
@@ -100,25 +170,27 @@ struct PlaygroundView: View {
100 170 )
101 171 )
102 172 }
103 .padding(ZyquoMetrics.contentInset)
104 .onAppear {
105 if modelID.isEmpty, let first = catalog.defaultModel {
106 modelID = RequestRouter.namespacedID(for: first)
107 }
108 }
173 + .padding(.horizontal, 1)
109 174 }
110 175
111 176 @State private var task: Task<Void, Never>?
112 177
113 178 private func send() {
114 179 output = ""
180 + rawResponse = ""
115 181 errorText = nil
116 182 running = true
117 let body: [String: Any] = [
183 + var body: [String: Any] = [
118 184 "model": modelID,
119 185 "messages": [["role": "user", "content": prompt]],
120 186 "stream": streaming,
121 187 ]
188 + if useTemperature { body["temperature"] = (temperature * 10).rounded() / 10 }
189 + if let limit = Int(maxTokens) { body["max_tokens"] = limit }
190 + if reasoningEffort != "off" { body["reasoning_effort"] = reasoningEffort }
191 + if let pretty = try? JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted, .sortedKeys]) {
192 + requestJSON = String(decoding: pretty, as: UTF8.self)
193 + }
122 194 let url = URL(string: "http://127.0.0.1:\(server.port)/v1/chat/completions")!
123 195 task = Task {
124 196 defer { running = false }
@@ -130,7 +202,9 @@ struct PlaygroundView: View {
130 202 if streaming {
131 203 let (bytes, _) = try await URLSession.shared.bytes(for: request)
132 204 for try await line in bytes.lines {
133 guard line.hasPrefix("data: "), !line.hasSuffix("[DONE]") else { continue }
205 + guard line.hasPrefix("data: ") else { continue }
206 + if rawResponse.count < 40_000 { rawResponse += line + "\n" }
207 + guard !line.hasSuffix("[DONE]") else { continue }
134 208 guard let json = try? JSONSerialization.jsonObject(with: Data(line.dropFirst(6).utf8)) as? [String: Any] else { continue }
135 209 if let error = json["error"] as? [String: Any] {
136 210 errorText = error["message"] as? String
@@ -144,10 +218,14 @@ struct PlaygroundView: View {
144 218 } else {
145 219 let (data, _) = try await URLSession.shared.data(for: request)
146 220 if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
221 + if let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {
222 + rawResponse = String(decoding: pretty, as: UTF8.self)
223 + }
147 224 if let error = json["error"] as? [String: Any] {
148 225 errorText = error["message"] as? String
149 } else if let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {
150 output = String(decoding: pretty, as: UTF8.self)
226 + } else {
227 + let message = ((json["choices"] as? [[String: Any]])?.first?["message"] as? [String: Any])
228 + output = message?["content"] as? String ?? ""
151 229 }
152 230 }
153 231 }
modified Sources/ZyquoRouter/Views/RequestsView.swift +253 −59
@@ -5,130 +5,324 @@
5 5 // Author: Simon-Pierre Boucher
6 6 // Mail: contact@spboucher.ai
7 7 //
8 // Live traffic. Phase 4 ships the table structure over UsageMeter records;
9 // the full request inspector (redacted bodies, timing waterfall, filters)
10 // lands with the observability work in Phase 6.
8 +// Live traffic: streaming log table (filters, pause/clear, export) and a
9 +// detail inspector with redacted-by-default bodies (explicit per-session
10 +// reveal) and the timing waterfall.
11 11 //
12 12
13 13 import SwiftUI
14 +import UniformTypeIdentifiers
14 15
15 16 struct RequestsView: View {
16 17 @EnvironmentObject private var server: ServerController
17 @State private var records: [UsageRecord] = []
18 private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
18 +
19 + @State private var entries: [RequestLogEntry] = []
20 + @State private var revision = -1
21 + @State private var paused = false
22 + @State private var filterText = ""
23 + @State private var providerFilter: ProviderID?
24 + @State private var errorsOnly = false
25 + @State private var selectedID: UUID?
26 + @AppStorage("logBodiesRevealed") private var bodiesRevealed = false
27 + @FocusState private var filterFocused: Bool
28 +
29 + private let refresh = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
30 +
31 + private var filtered: [RequestLogEntry] {
32 + entries.reversed().filter { entry in
33 + if let providerFilter, entry.provider != providerFilter { return false }
34 + if errorsOnly, entry.status < 400 { return false }
35 + if !filterText.isEmpty,
36 + !entry.namespacedModelID.localizedCaseInsensitiveContains(filterText) { return false }
37 + return true
38 + }
39 + }
19 40
20 41 var body: some View {
21 42 VStack(alignment: .leading, spacing: 0) {
22 SectionHeader(title: "Requests", subtitle: "Routed traffic, newest first")
23 .padding(ZyquoMetrics.contentInset)
24
43 + toolbar
25 44 ZyquoHairline()
26
27 if records.isEmpty {
45 + if filtered.isEmpty {
28 46 EmptyState(
29 47 systemImage: "antenna.radiowaves.left.and.right",
30 title: "No traffic yet",
31 message: server.isRunning
32 ? "Requests routed through \(server.endpointURL) will stream in here live."
33 : "Start the server, then point any OpenAI client at the endpoint."
48 + title: entries.isEmpty ? "No traffic yet" : "Nothing matches the filters",
49 + message: entries.isEmpty
50 + ? (server.isRunning
51 + ? "Requests routed through \(server.endpointURL) will stream in here live."
52 + : "Start the server, then point any OpenAI client at the endpoint.")
53 + : "Adjust or clear the filters above."
34 54 )
35 55 } else {
36 headerRow
37 ZyquoHairline()
38 ScrollView {
39 LazyVStack(spacing: 0) {
40 ForEach(records.reversed()) { record in
41 RequestRow(record: record)
42 ZyquoHairline()
43 .padding(.leading, ZyquoMetrics.contentInset)
44 }
56 + HSplitView {
57 + table
58 + .frame(minWidth: 460)
59 + if let selected = filtered.first(where: { $0.id == selectedID }) {
60 + RequestDetailPane(entry: selected, bodiesRevealed: $bodiesRevealed)
61 + .frame(minWidth: 320)
45 62 }
46 63 }
47 64 }
48 65 }
49 66 .onReceive(refresh) { _ in
50 let meter = server.usageMeter
67 + guard !paused else { return }
68 + let log = server.requestLog
51 69 Task {
52 let latest = await meter.records
53 if latest.count != records.count { records = latest }
70 + let newRevision = await log.revision
71 + if newRevision != revision {
72 + revision = newRevision
73 + entries = await log.entries
74 + }
54 75 }
55 76 }
56 77 }
57 78
58 private var headerRow: some View {
59 HStack(spacing: ZyquoSpacing.md) {
60 column("TIME", width: 70)
61 column("MODEL", width: nil)
62 column("STATUS", width: 52)
63 column("LATENCY", width: 64)
64 column("TOKENS", width: 88)
65 column("COST", width: 66)
79 + private var toolbar: some View {
80 + HStack(spacing: ZyquoSpacing.sm) {
81 + SectionHeader(title: "Requests")
82 + Spacer()
83 + Toggle("Errors only", isOn: $errorsOnly)
84 + .toggleStyle(.checkbox)
85 + .font(ZyquoFont.caption)
86 + Picker("", selection: $providerFilter) {
87 + Text("All providers").tag(ProviderID?.none)
88 + ForEach(ProviderID.builtIn) { provider in
89 + Text(provider.displayName).tag(ProviderID?.some(provider))
90 + }
91 + }
92 + .labelsHidden()
93 + .frame(width: 150)
94 + TextField("Filter by model… (⌘F)", text: $filterText)
95 + .textFieldStyle(.roundedBorder)
96 + .frame(width: 190)
97 + .focused($filterFocused)
98 + Button {
99 + paused.toggle()
100 + } label: {
101 + Image(systemName: paused ? "play.fill" : "pause.fill")
102 + }
103 + .help(paused ? "Resume live updates" : "Pause live updates")
104 + Button {
105 + let log = server.requestLog
106 + Task {
107 + await log.clear()
108 + entries = []
109 + selectedID = nil
110 + }
111 + } label: {
112 + Image(systemName: "trash")
113 + }
114 + .help("Clear the log")
115 + Button {
116 + exportLog()
117 + } label: {
118 + Image(systemName: "square.and.arrow.up")
119 + }
120 + .help("Export log metadata as JSON")
66 121 }
67 122 .padding(.horizontal, ZyquoMetrics.contentInset)
68 .padding(.vertical, ZyquoSpacing.xxs)
123 + .padding(.vertical, ZyquoSpacing.sm)
124 + .background(
125 + // ⌘F routes here from the app menu.
126 + Button("") { filterFocused = true }
127 + .keyboardShortcut("f", modifiers: .command)
128 + .hidden()
129 + )
69 130 }
70 131
71 private func column(_ title: String, width: CGFloat?) -> some View {
72 Text(title)
73 .font(.system(size: 9.5, weight: .semibold))
74 .foregroundStyle(ZyquoColor.textTertiary)
75 .kerning(0.4)
76 .frame(width: width, alignment: width == nil ? .leading : .trailing)
77 .frame(maxWidth: width == nil ? .infinity : width, alignment: .leading)
132 + private var table: some View {
133 + ScrollView {
134 + LazyVStack(spacing: 0) {
135 + ForEach(filtered) { entry in
136 + RequestRow(entry: entry, selected: entry.id == selectedID)
137 + .contentShape(Rectangle())
138 + .onTapGesture {
139 + selectedID = selectedID == entry.id ? nil : entry.id
140 + }
141 + ZyquoHairline()
142 + .padding(.leading, ZyquoMetrics.contentInset)
143 + }
144 + }
145 + }
146 + }
147 +
148 + private func exportLog() {
149 + let log = server.requestLog
150 + Task {
151 + let data = await log.exportJSON()
152 + let panel = NSSavePanel()
153 + panel.allowedContentTypes = [.json]
154 + panel.nameFieldStringValue = "zyquo-router-log.json"
155 + if panel.runModal() == .OK, let url = panel.url {
156 + try? data.write(to: url)
157 + }
158 + }
78 159 }
79 160 }
80 161
162 +// MARK: - Row
163 +
81 164 private struct RequestRow: View {
82 let record: UsageRecord
165 + let entry: RequestLogEntry
166 + let selected: Bool
167 + @State private var hovering = false
83 168
84 169 var body: some View {
85 HStack(spacing: ZyquoSpacing.md) {
86 Text(record.date, format: .dateTime.hour().minute().second())
170 + HStack(spacing: ZyquoSpacing.sm) {
171 + Text(entry.date, format: .dateTime.hour().minute().second())
87 172 .font(ZyquoFont.mono(size: 11))
88 173 .foregroundStyle(ZyquoColor.textSecondary)
89 .frame(width: 70, alignment: .leading)
174 + .frame(width: 64, alignment: .leading)
90 175
91 176 HStack(spacing: ZyquoSpacing.xs) {
92 177 Circle()
93 .fill(ZyquoColor.providerHue(record.provider))
178 + .fill(ZyquoColor.providerHue(entry.provider))
94 179 .frame(width: 6, height: 6)
95 Text(record.namespacedModelID)
180 + Text(entry.namespacedModelID)
96 181 .font(ZyquoFont.mono(size: 11.5))
97 182 .foregroundStyle(ZyquoColor.textPrimary)
98 183 .lineLimit(1)
99 184 .truncationMode(.middle)
100 if record.streamed {
185 + if entry.streamed {
101 186 CapabilityBadge(label: "SSE", tint: ZyquoColor.accent)
102 187 }
188 + if entry.usageEstimated {
189 + CapabilityBadge(label: "EST", tint: ZyquoColor.warning)
190 + }
103 191 }
104 192 .frame(maxWidth: .infinity, alignment: .leading)
105 193
106 Text("\(record.status)")
107 .font(ZyquoFont.mono(size: 11, weight: .medium))
108 .foregroundStyle(record.status < 400 ? ZyquoColor.success : ZyquoColor.danger)
109 .frame(width: 52, alignment: .trailing)
194 + Text("\(entry.status)")
195 + .font(ZyquoFont.mono(size: 11, weight: .semibold))
196 + .foregroundStyle(entry.status < 400 ? ZyquoColor.success : ZyquoColor.danger)
197 + .frame(width: 36, alignment: .trailing)
110 198
111 Text(String(format: "%.2fs", record.latency))
199 + Text(String(format: "%.2fs", entry.latency))
112 200 .font(ZyquoFont.mono(size: 11))
113 201 .foregroundStyle(ZyquoColor.textSecondary)
114 .frame(width: 64, alignment: .trailing)
202 + .frame(width: 56, alignment: .trailing)
115 203
116 Text("\(record.usage.inputTokens)\(record.usage.outputTokens)")
204 + Text("\(entry.usage.inputTokens)\(entry.usage.outputTokens)")
117 205 .font(ZyquoFont.mono(size: 11))
118 206 .foregroundStyle(ZyquoColor.textSecondary)
119 .frame(width: 88, alignment: .trailing)
207 + .frame(width: 84, alignment: .trailing)
120 208
121 209 Text(costText)
122 210 .font(ZyquoFont.mono(size: 11))
123 211 .foregroundStyle(ZyquoColor.textSecondary)
124 .frame(width: 66, alignment: .trailing)
212 + .frame(width: 58, alignment: .trailing)
125 213 }
126 214 .padding(.horizontal, ZyquoMetrics.contentInset)
127 215 .padding(.vertical, 6)
216 + .background(selected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary : .clear))
217 + .onHover { hover in
218 + withAnimation(ZyquoMotion.hover) { hovering = hover }
219 + }
128 220 }
129 221
130 222 private var costText: String {
131 guard let cost = record.estimatedCost, cost > 0 else { return "—" }
223 + guard let cost = entry.estimatedCost, cost > 0 else { return "—" }
132 224 return cost < 0.01 ? "<$0.01" : String(format: "$%.2f", cost)
133 225 }
134 226 }
227 +
228 +// MARK: - Detail inspector
229 +
230 +private struct RequestDetailPane: View {
231 + let entry: RequestLogEntry
232 + @Binding var bodiesRevealed: Bool
233 +
234 + var body: some View {
235 + ScrollView {
236 + VStack(alignment: .leading, spacing: ZyquoSpacing.md) {
237 + Text(entry.namespacedModelID)
238 + .font(ZyquoFont.mono(size: 13, weight: .medium))
239 + .foregroundStyle(ZyquoColor.textPrimary)
240 + .textSelection(.enabled)
241 +
242 + waterfall
243 +
244 + if let error = entry.errorMessage {
245 + Label(error, systemImage: "xmark.octagon")
246 + .font(ZyquoFont.body())
247 + .foregroundStyle(ZyquoColor.danger)
248 + .textSelection(.enabled)
249 + }
250 +
251 + Toggle("Reveal request/response bodies (this session)", isOn: $bodiesRevealed)
252 + .toggleStyle(.switch)
253 + .controlSize(.small)
254 + .font(ZyquoFont.caption)
255 +
256 + bodySection(title: "REQUEST", body: entry.requestBody)
257 + bodySection(title: "RESPONSE", body: entry.responseBody)
258 + }
259 + .padding(ZyquoSpacing.md)
260 + }
261 + .background(ZyquoColor.surface)
262 + }
263 +
264 + private var waterfall: some View {
265 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
266 + caption("TIMING")
267 + GeometryReader { proxy in
268 + let total = max(entry.latency, 0.001)
269 + let ttfb = min(entry.upstreamTTFB ?? entry.latency, total)
270 + HStack(spacing: 1) {
271 + RoundedRectangle(cornerRadius: 2)
272 + .fill(ZyquoColor.graphite.opacity(0.55))
273 + .frame(width: max(proxy.size.width * (ttfb / total), 2))
274 + RoundedRectangle(cornerRadius: 2)
275 + .fill(ZyquoColor.accent)
276 + .frame(maxWidth: .infinity)
277 + }
278 + }
279 + .frame(height: 8)
280 + HStack {
281 + Text(entry.upstreamTTFB.map { String(format: "upstream TTFB %.0f ms", $0 * 1000) } ?? "buffered call")
282 + Spacer()
283 + Text(String(format: "total %.2f s", entry.latency))
284 + }
285 + .font(ZyquoFont.mono(size: 10.5))
286 + .foregroundStyle(ZyquoColor.textSecondary)
287 + }
288 + }
289 +
290 + @ViewBuilder
291 + private func bodySection(title: String, body text: String) -> some View {
292 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
293 + HStack {
294 + caption(title)
295 + Spacer()
296 + if bodiesRevealed, !text.isEmpty {
297 + CopyButton(value: text)
298 + }
299 + }
300 + Group {
301 + if !bodiesRevealed {
302 + Text("Redacted — enable reveal above to inspect.")
303 + .font(ZyquoFont.caption)
304 + .foregroundStyle(ZyquoColor.textTertiary)
305 + .frame(maxWidth: .infinity, alignment: .leading)
306 + } else {
307 + Text(text.isEmpty ? "(empty)" : text)
308 + .font(ZyquoFont.mono(size: 10.5))
309 + .foregroundStyle(ZyquoColor.textPrimary)
310 + .textSelection(.enabled)
311 + .frame(maxWidth: .infinity, alignment: .leading)
312 + }
313 + }
314 + .padding(ZyquoSpacing.xs)
315 + .background(
316 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
317 + .fill(ZyquoColor.surfaceSecondary)
318 + )
319 + }
320 + }
321 +
322 + private func caption(_ text: String) -> some View {
323 + Text(text)
324 + .font(.system(size: 9.5, weight: .semibold))
325 + .foregroundStyle(ZyquoColor.textTertiary)
326 + .kerning(0.4)
327 + }
328 +}
modified Sources/ZyquoRouter/Views/SettingsView.swift +65 −1
@@ -10,7 +10,9 @@
10 10 // Phase 4; the rest fill in with Phase 6 features.
11 11 //
12 12
13 +import ServiceManagement
13 14 import SwiftUI
15 +import UniformTypeIdentifiers
14 16
15 17 struct SettingsView: View {
16 18 var body: some View {
@@ -36,6 +38,8 @@ private struct ServerSettings: View {
36 38 @EnvironmentObject private var server: ServerController
37 39 @AppStorage("autoStartServer") private var autoStart = false
38 40 @AppStorage("keepServingWhenClosed") private var keepServing = true
41 + @AppStorage("menuBarExtraEnabled") private var menuBarExtra = true
42 + @State private var launchAtLogin = SMAppService.mainApp.status == .enabled
39 43
40 44 var body: some View {
41 45 Form {
@@ -48,6 +52,19 @@ private struct ServerSettings: View {
48 52 .frame(width: 340)
49 53 Toggle("Start the server when the app launches", isOn: $autoStart)
50 54 Toggle("Keep serving when the window is closed", isOn: $keepServing)
55 + Toggle("Launch Zyquo Router at login", isOn: $launchAtLogin)
56 + .onChange(of: launchAtLogin) { enabled in
57 + do {
58 + if enabled {
59 + try SMAppService.mainApp.register()
60 + } else {
61 + try SMAppService.mainApp.unregister()
62 + }
63 + } catch {
64 + launchAtLogin = SMAppService.mainApp.status == .enabled
65 + }
66 + }
67 + Toggle("Show the menu bar extra", isOn: $menuBarExtra)
51 68 Text("Request body limit: 32 MB · streaming timeout: 15 min")
52 69 .font(ZyquoFont.caption)
53 70 .foregroundStyle(ZyquoColor.textSecondary)
@@ -72,12 +89,21 @@ private struct LoggingSettings: View {
72 89
73 90 private struct UsageSettings: View {
74 91 @EnvironmentObject private var catalog: ModelCatalog
92 + @EnvironmentObject private var server: ServerController
93 + @State private var confirmingReset = false
75 94
76 95 var body: some View {
77 96 Form {
78 97 Text("Costs are estimated from the catalog's per-model pricing (\(catalog.all.count) models). Usage counters reset daily at midnight.")
79 98 .font(ZyquoFont.body())
80 99 .foregroundStyle(ZyquoColor.textSecondary)
100 + Button("Reset usage counters…") { confirmingReset = true }
101 + .confirmationDialog("Clear all recorded usage and the request log?", isPresented: $confirmingReset) {
102 + Button("Reset", role: .destructive) {
103 + let log = server.requestLog
104 + Task { await log.clear() }
105 + }
106 + }
81 107 }
82 108 .padding(ZyquoSpacing.xl)
83 109 }
@@ -118,6 +144,8 @@ private struct ShortcutsSettings: View {
118 144 shortcut("⌘R", "Start / stop the server")
119 145 shortcut("⌘1–6", "Switch sections")
120 146 shortcut("⌘⇧C", "Copy endpoint URL")
147 + shortcut("⌘K", "Command palette")
148 + shortcut("⌘F", "Filter requests")
121 149 shortcut("⌘⏎", "Send in Playground")
122 150 }
123 151 .padding(ZyquoSpacing.xl)
@@ -137,15 +165,51 @@ private struct ShortcutsSettings: View {
137 165 }
138 166
139 167 private struct AdvancedSettings: View {
168 + @EnvironmentObject private var routerConfig: RouterConfigStore
169 + @State private var importError: String?
170 +
140 171 var body: some View {
141 172 Form {
142 173 Button("Reveal data folder in Finder") {
143 174 NSWorkspace.shared.activateFileViewerSelecting([PersistenceService.shared.rootDirectory])
144 175 }
145 Text("Config lives in ~/Library/Application Support/ZyquoRouter/ — provider keys stay in the encrypted vault (vault.zq) and are never exported in plaintext.")
176 + HStack {
177 + Button("Export config…") { exportConfig() }
178 + Button("Import config…") { importConfig() }
179 + }
180 + if let importError {
181 + Text(importError)
182 + .font(ZyquoFont.caption)
183 + .foregroundStyle(ZyquoColor.danger)
184 + }
185 + Text("Config export covers aliases, fallback chains, disabled models, and favorites — provider keys stay in the encrypted vault (vault.zq) and are never exported in plaintext.")
146 186 .font(ZyquoFont.caption)
147 187 .foregroundStyle(ZyquoColor.textSecondary)
148 188 }
149 189 .padding(ZyquoSpacing.xl)
150 190 }
191 +
192 + private func exportConfig() {
193 + guard let data = try? routerConfig.exportData() else { return }
194 + let panel = NSSavePanel()
195 + panel.allowedContentTypes = [.json]
196 + panel.nameFieldStringValue = "zyquo-router-config.json"
197 + if panel.runModal() == .OK, let url = panel.url {
198 + try? data.write(to: url)
199 + }
200 + }
201 +
202 + private func importConfig() {
203 + let panel = NSOpenPanel()
204 + panel.allowedContentTypes = [.json]
205 + panel.allowsMultipleSelection = false
206 + if panel.runModal() == .OK, let url = panel.url {
207 + do {
208 + try routerConfig.importData(try Data(contentsOf: url))
209 + importError = nil
210 + } catch {
211 + importError = "Not a valid Zyquo Router config file."
212 + }
213 + }
214 + }
151 215 }
modified docs/PLAN.md +33 −1
@@ -171,6 +171,38 @@ bundle carries the icns, and the icon verified in the Dock at small size.
171 171
172 172
173 173
174 ## Phase 6 — Features — pending
174 +## Phase 6 — Features
175 +
176 +- [x] 6.1 `RouterConfigStore` (aliases, fallback chains, disabled models, favorites) persisted to router-config.json; snapshot feeds `RequestRouter` at Start; Models screen gets enable/disable toggles, favorites, alias editor, fallback-chain editor
177 +- [x] 6.2 `RequestLogStore` (ring buffer, redacted-by-default bodies, per-session reveal) wired into the chat route with timing (TTFB/duration); Requests screen: live table, filters (provider/status/model), pause/clear, detail pane with pretty JSON + timing waterfall, ⌘F focuses filter, export logs
178 +- [x] 6.3 Dashboard: requests/min sparkline (cyan), per-provider breakdown bar, active streams tile; latency in tiles
179 +- [x] 6.4 Menu bar extra (first-class): template glyph, status + port, Start/Stop, req/min, today's cost, Copy endpoint URL; toggleable in Settings
180 +- [x] 6.5 Playground: params (temperature/top_p/max_tokens/reasoning), side-by-side raw request/response JSON with copy-as-code
181 +- [x] 6.6 Launch at login (SMAppService) + auto-start (done) + keep-serving (done); ⌘K command palette (start/stop, sections, copy endpoint, model search)
182 +- [x] 6.7 Settings: Logging (retention/export), Usage (reset counters), Advanced (export/import config without keys)
183 +- [x] 6.8 Phase 4 design quality gate re-run across all states; zero warnings; headers sweep
184 +
185 +**Phase gate: PASSED (2026-07-30).**
186 +
187 +**Phase 6 summary:** `RouterConfigStore` (aliases/chains/disabled/favorites →
188 +router-config.json, snapshotted into `RequestRouter` at Start, honored by headless
189 +`--serve` too); Models screen gained enable/disable switches (strikethrough + 404),
190 +favorite stars, an alias editor and a drag-reorderable fallback-chain editor with CHAIN
191 +badges. `RequestLogStore` (500-entry ring, bodies in memory only) wired through the chat
192 +route with upstream TTFB; Requests is now a live 0.5 Hz table with provider/status/model
193 +filters (⌘F), pause/clear/export, and a detail inspector (timing waterfall, redacted-by-
194 +default bodies with per-session reveal, error detail). Dashboard: requests/min sparkline
195 +(cyan token), per-provider breakdown bar + legend, active-streams tile. First-class
196 +MenuBarExtra (template Z glyph, status, Start/Stop, req/min + cost, copy endpoint,
197 +toggleable). Playground: temperature/max_tokens/reasoning controls + side-by-side
198 +REQUEST JSON / RAW RESPONSE panes with copy. ⌘K command palette (server, sections,
199 +copy endpoint, model-ID search/copy); ⌘R moved to a Server menu so it works from every
200 +screen (bug found in live pass). Settings: launch-at-login (SMAppService), menu-bar
201 +toggle, usage reset, config export/import (never keys). Live-verified end-to-end with
202 +real traffic: tiles, sparkline, breakdown, log rows + SSE badges all populate. 31 tests
203 +green; zero warnings; headers swept.
204 +
205 +
206 +
175 207 ## Phase 7 — Verification with real keys — pending
176 208 ## Phase 8 — Signing & notarization — pending
177 209