phase6: features — router config + editors, request log + inspector, live charts, menu bar extra, palette, settings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | |