on master help sign in
feat: add z.ai Coding Plan usage to menu bar

Add z.ai as a third service alongside Hyper and Claude Code: - ZaiUsageClient: fetches quota/limit + subscription endpoints concurrently - ZaiKeychainHelper: separate keychain entry for the z.ai API key - ViewModel: triple async let (Hyper + Claude + z.ai), ๐Ÿค– emoji in title - MenuView: z.ai section with 5-hour subhero + weekly window, settings field - statusBarText: ๐Ÿค–{percent}% shown when z.ai 5-hour > 0% - Tests: ZaiUsageClientTests + updated StatusBarTextTests z.ai API: https://api.z.ai/api - /monitor/usage/quota/limit (5-hour + weekly token quotas) - /biz/subscription/list (plan name) - Bearer auth with user-entered API key

commite71c8bf66affb0924ec3d5fd1abf032db77892ba authoriko <mail@iko.soy> dateFri 17 Jul 2026, 08:11 (2 months ago) parent2e6d845
8 files changed+1048โˆ’37
CLAUDE.md+10โˆ’7 UsageMenubar/KeychainHelper.swift+28 UsageMenubar/MenuView.swift+137 UsageMenubar/UsageMenubarApp.swift+9โˆ’6 UsageMenubar/ViewModel.swift+156โˆ’13 UsageMenubar/ZaiUsageClient.swift+388 UsageMenubarTests/StatusBarTextTests.swift+78โˆ’11 UsageMenubarTests/ZaiUsageClientTests.swift+242
CLAUDE.md+10โˆ’7
@@ -2,18 +2,21 @@
22
33## Architecture
44macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5โˆ’Claude Code's Pro/Max usage limits when the CLI is signed in on the machine.
5+Claude Code's Pro/Max usage limits when the CLI is signed in on the machine, plus
6+z.ai Coding Plan usage limits when an API key is entered in settings.
67- `UsageMenubarApp.swift` โ€” @main App, NSStatusBar, timer, sleep/wake, popover
7โˆ’- `ViewModel.swift` โ€” Observable state: balance, Claude usage, refresh, notifications, history
8โˆ’- `MenuView.swift` โ€” SwiftUI popover view (balance, sparkline, Claude limits, settings)
8+- `ViewModel.swift` โ€” Observable state: balance, Claude usage, z.ai usage, refresh, notifications, history
9+- `MenuView.swift` โ€” SwiftUI popover view (balance, sparkline, Claude limits, z.ai limits, settings)
910- `CreditsChecker.swift` โ€” API client for `GET https://hyper.charm.land/v1/credits`
1011- `ClaudeUsageClient.swift` โ€” read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
11โˆ’- `KeychainHelper.swift` โ€” Keychain wrapper (KeychainStore + KeychainHelper)
12+- `ZaiUsageClient.swift` โ€” API client for z.ai coding plan usage (`GET https://api.z.ai/api/monitor/usage/quota/limit` + `GET https://api.z.ai/api/biz/subscription/list`)
13+- `KeychainHelper.swift` โ€” Keychain wrapper (KeychainStore + KeychainHelper + ZaiKeychainHelper)
1214- `VersionFormatter.swift` โ€” Version string formatting
1315
14โˆ’The status bar title stays `โšก{balance}` (Hyper only); Claude usage lives in the popover.
15โˆ’The two fetches are independent โ€” `ViewModel.refresh()` runs them with `async let`, and
16โˆ’either can fail or be unconfigured without touching the other.
16+The status bar title shows `โšก{balance}` (Hyper), `๐Ÿ•{percent}%` (Claude 5-hour),
17+`๐Ÿ“…{percent}%` (Claude 7-day), and `๐Ÿค–{percent}%` (z.ai 5-hour) โ€” each omitted when
18+absent or at 0%. The three fetches are independent โ€” `ViewModel.refresh()` runs them
19+with `async let`, and any can fail or be unconfigured without touching the others.
1720
1821## Claude credentials (strictly read-only)
1922`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
UsageMenubar/KeychainHelper.swift+28
@@ -106,3 +106,31 @@ enum KeychainHelper {
106106 store.delete()
107107 }
108108}
109+
110+/// Wrapper around the macOS Keychain for storing the z.ai API key.
111+///
112+/// Uses the same keychain service as the Hyper key but a different account, so the
113+/// two keys coexist without clobbering each other.
114+enum ZaiKeychainHelper {
115+ private static let service = "com.ilyakooo0.usage-menubar"
116+ private static let account = "zai-api-key"
117+
118+ private static let store = KeychainStore(service: service, account: account)
119+
120+ /// Saves the given z.ai API key to the Keychain, overwriting any existing entry.
121+ @discardableResult
122+ static func save(_ key: String) -> Bool {
123+ store.save(key)
124+ }
125+
126+ /// Retrieves the stored z.ai API key, or `nil` if none exists.
127+ static func load() -> String? {
128+ store.load()
129+ }
130+
131+ /// Deletes the stored z.ai API key if it exists.
132+ @discardableResult
133+ static func delete() -> Bool {
134+ store.delete()
135+ }
136+}
UsageMenubar/MenuView.swift+137
@@ -8,6 +8,7 @@ struct MenuView: View {
88 @State private var isHoveringBalance = false
99
1010 private static let websiteURL = URL(string: "https://hyper.charm.land")!
11+ private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
1112
1213 // MARK: - Type Scale
1314
@@ -32,6 +33,10 @@ struct MenuView: View {
3233 serviceBreak
3334 claude
3435 }
36+ if showsZai {
37+ serviceBreak
38+ zai
39+ }
3540 hairline
3641 settings
3742 hairline
@@ -52,6 +57,8 @@ struct MenuView: View {
5257 .animation(.easeInOut(duration: 0.3), value: historyValues.count)
5358 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
5459 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60+ .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
61+ .animation(.easeInOut(duration: 0.2), value: viewModel.zaiError)
5562 }
5663
5764 /// A section rule light enough to read as a pause rather than a border.
@@ -311,6 +318,105 @@ struct MenuView: View {
311318 return .green
312319 }
313320
321+ // MARK: - z.ai
322+
323+ /// Whether there is anything to say about z.ai. Nothing at all when no API key has
324+ /// been entered โ€” the section doesn't appear, and no error is shown for a service
325+ /// the user may simply not use.
326+ private var showsZai: Bool {
327+ if viewModel.zaiError != nil { return true }
328+ if let usage = viewModel.zaiUsage { return !usage.isEmpty }
329+ return false
330+ }
331+
332+ /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
333+ /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
334+ private var zai: some View {
335+ VStack(alignment: .leading, spacing: 12) {
336+ HStack(spacing: 8) {
337+ sectionHeader("z.ai Coding", systemImage: "bolt.horizontal")
338+
339+ Spacer(minLength: 0)
340+
341+ if let plan = viewModel.zaiPlanLabel {
342+ Text(plan)
343+ .font(Self.captionFont)
344+ .foregroundColor(.secondary)
345+ }
346+ }
347+
348+ if let usage = viewModel.zaiUsage, !usage.isEmpty {
349+ zaiUsageDetail(usage)
350+ }
351+
352+ if let error = viewModel.zaiError {
353+ errorRow(error)
354+ }
355+ }
356+ }
357+
358+ private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
359+ VStack(alignment: .leading, spacing: 12) {
360+ // The 5-hour window leads as the hero number, same as Claude's.
361+ if let fiveHour = usage.fiveHourPercent {
362+ HStack(alignment: .firstTextBaseline, spacing: 8) {
363+ Text("๐Ÿค– \(fiveHour)%")
364+ .font(Self.subheroFont)
365+ .foregroundColor(Self.usageColor(fiveHour))
366+ .contentTransition(.opacity)
367+
368+ Spacer(minLength: 0)
369+
370+ if let resets = usage.fiveHourResetsAt {
371+ Text("resets in \(Self.zaiResetsIn(from: resets))")
372+ .font(Self.footnoteFont)
373+ .foregroundColor(.secondary)
374+ }
375+ }
376+ }
377+
378+ VStack(alignment: .leading, spacing: 9) {
379+ if let weekly = usage.weeklyPercent {
380+ zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
381+ }
382+ }
383+ }
384+ }
385+
386+ /// One window row for z.ai: label, percentage, and a hairline bar.
387+ private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
388+ VStack(alignment: .leading, spacing: 5) {
389+ HStack(spacing: 6) {
390+ Text(label)
391+ .font(Self.captionFont)
392+ .foregroundColor(.secondary)
393+
394+ Spacer(minLength: 0)
395+
396+ Text("\(percent)%")
397+ .font(Self.captionFont.monospacedDigit())
398+ .foregroundColor(Self.usageColor(percent))
399+ }
400+
401+ UsageBar(percent: percent, color: Self.usageColor(percent))
402+ }
403+ }
404+
405+ /// Formats the time until a z.ai window resets, e.g. "3h 20m".
406+ private static func zaiResetsIn(from date: Date) -> String {
407+ let interval = date.timeIntervalSince(Date())
408+ guard interval > 0 else { return "soon" }
409+
410+ let seconds = Int(interval.rounded())
411+ let days = seconds / 86_400
412+ let hours = (seconds % 86_400) / 3_600
413+ let minutes = (seconds % 3_600) / 60
414+
415+ if days > 0 { return "\(days)d \(hours)h" }
416+ if hours > 0 { return "\(hours)h \(minutes)m" }
417+ return "\(minutes)m"
418+ }
419+
314420 // MARK: - Settings
315421
316422 private var settings: some View {
@@ -346,6 +452,37 @@ struct MenuView: View {
346452 .controlSize(.small)
347453 }
348454
455+ VStack(alignment: .leading, spacing: 8) {
456+ HStack(spacing: 8) {
457+ sectionHeader("z.ai API Key", systemImage: "bolt.horizontal")
458+
459+ Spacer(minLength: 0)
460+
461+ // The confirmation takes the link's place rather than adding a row
462+ // of its own, so saving never resizes the popover.
463+ if viewModel.savedConfirmation {
464+ Text("โœ“ Saved")
465+ .font(Self.captionFont)
466+ .foregroundColor(.green)
467+ .transition(.opacity)
468+ } else {
469+ Link("Get key โ†’", destination: Self.zaiKeyURL)
470+ .font(Self.captionFont)
471+ .transition(.opacity)
472+ }
473+ }
474+
475+ HStack(spacing: 8) {
476+ SecureField("your z.ai key", text: $viewModel.zaiAPIKeyInput)
477+ .textFieldStyle(.roundedBorder)
478+
479+ Button("Save") {
480+ viewModel.saveZaiAPIKey()
481+ }
482+ }
483+ .controlSize(.small)
484+ }
485+
349486 HStack {
350487 Text("Refresh every")
351488
UsageMenubar/UsageMenubarApp.swift+9โˆ’6
@@ -46,8 +46,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4646 popover.behavior = .transient
4747 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4848
49โˆ’ // Observe published changes (balance + isLoading + Claude usage) to update the
50โˆ’ // menu bar title. CombineLatest fires when any of them changes.
49+ // Observe published changes (balance + isLoading + Claude usage + z.ai usage) to
50+ // update the menu bar title. CombineLatest fires when any of them changes.
5151 //
5252 // The title is built from the values the publisher hands us, not by reading the
5353 // view model back: `@Published` emits in `willSet`, so the property still holds
@@ -56,23 +56,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5656 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
5757 // whose Combine scheduler only runs in the default run loop mode โ€” the title
5858 // would freeze while a menu or a scroll was tracking.
59โˆ’ statusTextCancellable = Publishers.CombineLatest3(
59+ statusTextCancellable = Publishers.CombineLatest4(
6060 viewModel.$balance,
6161 viewModel.$isLoading,
62โˆ’ viewModel.$claudeUsage
62+ viewModel.$claudeUsage,
63+ viewModel.$zaiUsage
6364 )
64โˆ’ .map { balance, isLoading, claudeUsage in
65+ .map { balance, isLoading, claudeUsage, zaiUsage in
6566 let fiveHour = claudeUsage?.fiveHour
6667 .map { Int($0.utilization.rounded()) }
6768 let sevenDay = claudeUsage?.sevenDay
6869 .map { Int($0.utilization.rounded()) }
6970 ?? claudeUsage?.sevenDayOpus
7071 .map { Int($0.utilization.rounded()) }
72+ let zaiFiveHour = zaiUsage?.fiveHourPercent
7173 return ViewModel.statusBarText(
7274 balance: balance,
7375 isLoading: isLoading,
7476 claudeFiveHourPercent: fiveHour,
75โˆ’ claudeSevenDayPercent: sevenDay
77+ claudeSevenDayPercent: sevenDay,
78+ zaiFiveHourPercent: zaiFiveHour
7679 )
7780 }
7881 .removeDuplicates()
UsageMenubar/ViewModel.swift+156โˆ’13
@@ -80,6 +80,19 @@ final class ViewModel: ObservableObject {
8080 /// The Claude plan the credentials belong to โ€” `"pro"`, `"max"`, โ€ฆ
8181 @Published var claudePlan: String?
8282
83+ /// z.ai Coding Plan usage. `nil` when no z.ai API key has been entered, in which
84+ /// case the popover leaves the section out entirely.
85+ @Published var zaiUsage: ZaiUsageReport?
86+
87+ /// A human-readable error message from the z.ai fetch. Independent of
88+ /// `errorMessage` and `claudeError`: the three services fail separately.
89+ @Published var zaiError: String?
90+
91+ /// The z.ai API key draft bound to the text field. Two-way bound to the
92+ /// `SecureField`, so it changes on every keystroke โ€” never fetch with it, use
93+ /// `activeZaiAPIKey`.
94+ @Published var zaiAPIKeyInput: String = ""
95+
8396 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
8497 /// so it changes on every keystroke โ€” never fetch with it, use `activeAPIKey`.
8598 @Published var apiKeyInput: String = ""
@@ -125,8 +138,13 @@ final class ViewModel: ObservableObject {
125138 /// last-updated time and history out from under the user.
126139 private var activeAPIKey: String
127140
141+ /// The z.ai key requests actually go out with: what is in the Keychain, not what
142+ /// is currently in the text field. Same rationale as `activeAPIKey`.
143+ private var activeZaiAPIKey: String
144+
128145 private let checker: CreditsChecking
129146 private let claudeChecker: ClaudeUsageChecking
147+ private let zaiChecker: ZaiUsageChecking
130148 private let defaults: UserDefaults
131149
132150 /// The currently in-flight refresh task, if any. Used to cancel
@@ -150,14 +168,19 @@ final class ViewModel: ObservableObject {
150168 init(
151169 checker: CreditsChecking = CreditsChecker(),
152170 claudeChecker: ClaudeUsageChecking = ClaudeUsageClient(),
171+ zaiChecker: ZaiUsageChecking = ZaiUsageClient(),
153172 defaults: UserDefaults = .standard
154173 ) {
155174 self.checker = checker
156175 self.claudeChecker = claudeChecker
176+ self.zaiChecker = zaiChecker
157177 self.defaults = defaults
158178 let storedKey = KeychainHelper.load() ?? ""
159179 apiKeyInput = storedKey
160180 activeAPIKey = storedKey
181+ let storedZaiKey = ZaiKeychainHelper.load() ?? ""
182+ zaiAPIKeyInput = storedZaiKey
183+ activeZaiAPIKey = storedZaiKey
161184 // Assigning in init doesn't fire didSet, so this load doesn't re-post
162185 // the change notification or write back to defaults.
163186 refreshIntervalMinutes = RefreshInterval.stored(in: defaults)
@@ -188,9 +211,10 @@ final class ViewModel: ObservableObject {
188211
189212 /// The text to show in the menu bar: `โšก{balance}` followed by Claude usage
190213 /// windows, each prefixed with an emoji so they are distinguishable at a glance:
191โˆ’ /// ๐Ÿ• for the 5-hour window, ๐Ÿ“… for the 7-day window. Windows that are absent or
192โˆ’ /// at 0% are omitted; with neither, the bolt stands in alone: `โšกโ€ฆ` while the
193โˆ’ /// first fetch is in flight, `โšก?` once it has come back empty-handed.
214+ /// ๐Ÿ• for the 5-hour window, ๐Ÿ“… for the 7-day window, ๐Ÿค– for the z.ai 5-hour
215+ /// window. Windows that are absent or at 0% are omitted; with neither, the bolt
216+ /// stands in alone: `โšกโ€ฆ` while the first fetch is in flight, `โšก?` once it has
217+ /// come back empty-handed.
194218 ///
195219 /// Takes its values as parameters rather than reading the properties: `@Published`
196220 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -199,7 +223,8 @@ final class ViewModel: ObservableObject {
199223 balance: Int?,
200224 isLoading: Bool,
201225 claudeFiveHourPercent: Int?,
202โˆ’ claudeSevenDayPercent: Int?
226+ claudeSevenDayPercent: Int?,
227+ zaiFiveHourPercent: Int?
203228 ) -> String {
204229 var parts: [String] = []
205230 if let balance = balance {
@@ -211,6 +236,9 @@ final class ViewModel: ObservableObject {
211236 if let sevenDay = claudeSevenDayPercent, sevenDay > 0 {
212237 parts.append("๐Ÿ“…\(sevenDay)%")
213238 }
239+ if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
240+ parts.append("๐Ÿค–\(zaiFiveHour)%")
241+ }
214242 if parts.isEmpty {
215243 return isLoading ? "โšกโ€ฆ" : "โšก?"
216244 }
@@ -223,7 +251,8 @@ final class ViewModel: ObservableObject {
223251 balance: balance,
224252 isLoading: isLoading,
225253 claudeFiveHourPercent: claudeFiveHourPercent,
226โˆ’ claudeSevenDayPercent: claudeSevenDayPercent
254+ claudeSevenDayPercent: claudeSevenDayPercent,
255+ zaiFiveHourPercent: zaiFiveHourPercent
227256 )
228257 }
229258
@@ -279,19 +308,20 @@ final class ViewModel: ObservableObject {
279308
280309 // MARK: - Refresh
281310
282โˆ’ /// Refreshes the Hyper balance and the Claude usage, both at once.
311+ /// Refreshes the Hyper balance, the Claude usage, and the z.ai usage, all at once.
283312 ///
284โˆ’ /// The two are independent: either can fail, or not be configured at all, without
285โˆ’ /// touching the other. Cancels any previously in-flight refresh to prevent races.
313+ /// The three are independent: any can fail, or not be configured at all, without
314+ /// touching the others. Cancels any previously in-flight refresh to prevent races.
286315 func refresh() {
287316 refreshTask?.cancel()
288317
289318 // Deliberately the saved key rather than `apiKeyInput`: see `activeAPIKey`.
290319 let key = activeAPIKey
320+ let zaiKey = activeZaiAPIKey
291321 if key.isEmpty {
292322 // Nothing to fetch, and nothing worth keeping: the balance, its history and
293โˆ’ // any error all belonged to a key that is no longer there. Claude is left
294โˆ’ // alone โ€” it doesn't depend on the Hyper key.
323+ // any error all belonged to a key that is no longer there. Claude and z.ai
324+ // are left alone โ€” they don't depend on the Hyper key.
295325 balance = nil
296326 errorMessage = nil
297327 lastUpdated = nil
@@ -302,22 +332,25 @@ final class ViewModel: ObservableObject {
302332 // Check cancellation before setting isLoading โ€” a previous
303333 // refresh() may have cancelled us and already set isLoading = false.
304334 guard !Task.isCancelled else { return }
305โˆ’ // Loading state covers both fetches: the popover's loading view depends
306โˆ’ // on it, and Claude usage is fetched even when there's no Hyper key.
335+ // Loading state covers all fetches: the popover's loading view depends
336+ // on it, and Claude/z.ai usage is fetched even when there's no Hyper key.
307337 isLoading = true
308338 // Don't clear the errors here โ€” keep showing the last one
309339 // until we have a successful result.
310340
311โˆ’ // Both requests go out at once; neither waits on the other.
341+ // All three requests go out at once; none waits on the others.
312342 async let balanceOutcome = fetchBalance(key: key)
313343 async let claudeOutcome = fetchClaudeUsage()
344+ async let zaiOutcome = fetchZaiUsage(key: zaiKey)
314345
315346 let balanceResult = await balanceOutcome
316347 let claudeResult = await claudeOutcome
348+ let zaiResult = await zaiOutcome
317349 guard !Task.isCancelled else { return }
318350
319351 applyBalance(balanceResult)
320352 applyClaude(claudeResult)
353+ applyZai(zaiResult)
321354 isLoading = false
322355 }
323356 }
@@ -436,6 +469,70 @@ final class ViewModel: ObservableObject {
436469 }
437470 }
438471
472+ // MARK: - z.ai Usage
473+
474+ /// What a z.ai fetch produced. Missing API key is deliberately not a failure:
475+ /// plenty of people don't use z.ai, and an error telling them so would be noise.
476+ private enum ZaiOutcome {
477+ case notConfigured
478+ case usage(ZaiUsageReport)
479+ case failure(String)
480+ }
481+
482+ private func fetchZaiUsage(key: String) async -> ZaiOutcome {
483+ guard !key.isEmpty else { return .notConfigured }
484+ do {
485+ return .usage(try await zaiChecker.fetchUsage(apiKey: key))
486+ } catch ZaiUsageError.noAPIKey {
487+ return .notConfigured
488+ } catch {
489+ return .failure(error.localizedDescription)
490+ }
491+ }
492+
493+ private func applyZai(_ outcome: ZaiOutcome) {
494+ switch outcome {
495+ case .notConfigured:
496+ zaiUsage = nil
497+ zaiError = nil
498+
499+ case .usage(let report):
500+ zaiUsage = report
501+ zaiError = nil
502+
503+ case .failure(let message):
504+ // As with the balance and Claude: keep the last good numbers on screen
505+ // and say what went wrong, rather than blanking the section on a
506+ // transient failure.
507+ zaiError = message
508+ }
509+ }
510+
511+ /// The 5-hour window percentage for the menu bar title, or `nil` when absent.
512+ var zaiFiveHourPercent: Int? {
513+ zaiUsage?.fiveHourPercent
514+ }
515+
516+ /// The weekly window percentage for the popover, or `nil` when absent.
517+ var zaiWeeklyPercent: Int? {
518+ zaiUsage?.weeklyPercent
519+ }
520+
521+ /// Display label for the z.ai plan, e.g. "Lite", "Pro", or "Max".
522+ /// Falls back to the raw `planLevel` string if it doesn't match a known tier.
523+ var zaiPlanLabel: String? {
524+ guard let level = zaiUsage?.planLevel?.trimmingCharacters(in: .whitespacesAndNewlines),
525+ !level.isEmpty else { return nil }
526+
527+ // The subscription endpoint returns a product name like "GLM Coding Max";
528+ // extract the tier from it. The quota endpoint returns just the tier.
529+ let lowered = level.lowercased()
530+ if lowered.contains("max") { return "Max" }
531+ if lowered.contains("pro") { return "Pro" }
532+ if lowered.contains("lite") { return "Lite" }
533+ return level.capitalized
534+ }
535+
439536 // MARK: - Clipboard
440537
441538 /// Copies the current balance to the clipboard as plain digits (no grouping
@@ -537,6 +634,52 @@ final class ViewModel: ObservableObject {
537634 }
538635 }
539636
637+ // MARK: - z.ai API Key
638+
639+ /// Saves the current `zaiAPIKeyInput` to the Keychain and triggers a refresh.
640+ /// Shows "โœ“ Saved" on success, or an error message if the keychain rejected the key.
641+ func saveZaiAPIKey() {
642+ let key = zaiAPIKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
643+ if key.isEmpty {
644+ let deleted = ZaiKeychainHelper.delete()
645+ guard deleted else {
646+ zaiError = "Could not delete z.ai API key from Keychain. It may be locked."
647+ return
648+ }
649+ zaiAPIKeyInput = ""
650+ activeZaiAPIKey = ""
651+ // refresh() with an empty key clears zai usage and error, and
652+ // cancels any in-flight task from the previous key.
653+ refresh()
654+ } else {
655+ let saved = ZaiKeychainHelper.save(key)
656+ guard saved else {
657+ zaiError = "Could not save z.ai API key to Keychain. It may be locked."
658+ return
659+ }
660+ // A different key is a different account, so everything derived from the
661+ // old one has to go.
662+ if key != activeZaiAPIKey {
663+ zaiUsage = nil
664+ zaiError = nil
665+ }
666+ // Show the trimmed key that was actually stored.
667+ zaiAPIKeyInput = key
668+ activeZaiAPIKey = key
669+ refresh()
670+ }
671+
672+ // Reuse the same "โœ“ Saved" confirmation โ€” the user sees one per save action,
673+ // regardless of which key it was for.
674+ savedConfirmation = true
675+ savedResetTask?.cancel()
676+ savedResetTask = Task { @MainActor in
677+ try? await Task.sleep(nanoseconds: 2_000_000_000)
678+ guard !Task.isCancelled else { return }
679+ savedConfirmation = false
680+ }
681+ }
682+
540683 // MARK: - Launch at Login
541684
542685 func updateLaunchAtLogin(_ enabled: Bool) {
UsageMenubar/ZaiUsageClient.swiftnew file+388
@@ -0,0 +1,388 @@
1+import Foundation
2+
3+// MARK: - Usage
4+
5+/// Usage data from z.ai's coding plan API โ€” two endpoints that together give the
6+/// plan level, the 5-hour token quota, and the weekly token quota.
7+///
8+/// Every field in the report is optional. The quota endpoint may return fewer limits
9+/// than expected, and the subscription endpoint may return an empty list, so an
10+/// unrecognized shape degrades to "nothing to show" rather than failing the decode.
11+struct ZaiUsageReport: Equatable {
12+ /// Percent of the 5-hour token window consumed, 0โ€“100.
13+ let fiveHourPercent: Int?
14+ /// Percent of the weekly token window consumed, 0โ€“100.
15+ let weeklyPercent: Int?
16+ /// When the 5-hour window resets, or `nil` when not provided.
17+ let fiveHourResetsAt: Date?
18+ /// When the weekly window resets, or `nil` when not provided.
19+ let weeklyResetsAt: Date?
20+ /// The plan tier from the quota response: `"lite"`, `"pro"`, `"max"`.
21+ let planLevel: String?
22+
23+ /// Whether there is anything at all to render. A report with all-nil fields reads
24+ /// as a section header over nothing, which looks like a bug.
25+ var isEmpty: Bool {
26+ fiveHourPercent == nil && weeklyPercent == nil && planLevel == nil
27+ }
28+}
29+
30+// MARK: - API Response Models
31+
32+/// Response from `GET https://api.z.ai/api/monitor/usage/quota/limit`.
33+struct ZaiQuotaResponse: Decodable, Equatable {
34+ let code: Int
35+ let msg: String?
36+ let data: ZaiQuotaData?
37+ let success: Bool
38+
39+ private enum CodingKeys: String, CodingKey {
40+ case code, msg, data, success
41+ }
42+
43+ init(from decoder: Decoder) throws {
44+ let container = try decoder.container(keyedBy: CodingKeys.self)
45+ code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
46+ msg = try container.decodeIfPresent(String.self, forKey: .msg)
47+ data = try container.decodeIfPresent(ZaiQuotaData.self, forKey: .data)
48+ success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
49+ }
50+}
51+
52+struct ZaiQuotaData: Decodable, Equatable {
53+ let limits: [ZaiLimit]?
54+ let level: String?
55+
56+ private enum CodingKeys: String, CodingKey {
57+ case limits, level
58+ }
59+
60+ init(from decoder: Decoder) throws {
61+ let container = try decoder.container(keyedBy: CodingKeys.self)
62+ limits = try container.decodeIfPresent([ZaiLimit].self, forKey: .limits)
63+ level = try container.decodeIfPresent(String.self, forKey: .level)
64+ }
65+}
66+
67+/// One limit entry from the quota response.
68+///
69+/// `unit` identifies the window: 3 = 5-hour, 6 = weekly, 5 = monthly tools.
70+/// `type` is `"TOKENS_LIMIT"` for token quotas or `"TIME_LIMIT"` for tool-call quotas.
71+struct ZaiLimit: Decodable, Equatable {
72+ let type: String
73+ let unit: Int
74+ let percentage: Int
75+ /// Epoch milliseconds, or `nil` when nothing has been used.
76+ let nextResetTime: Int64?
77+
78+ private enum CodingKeys: String, CodingKey {
79+ case type, unit, percentage, nextResetTime
80+ }
81+
82+ init(from decoder: Decoder) throws {
83+ let container = try decoder.container(keyedBy: CodingKeys.self)
84+ type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
85+ unit = try container.decodeIfPresent(Int.self, forKey: .unit) ?? 0
86+ // Decoded as Double and rounded: the field is documented as an integer, but
87+ // a fractional value would throw and take the whole payload down with it.
88+ let rawPercent = try container.decodeIfPresent(Double.self, forKey: .percentage) ?? 0
89+ percentage = Int(rawPercent.rounded())
90+ nextResetTime = try container.decodeIfPresent(Int64.self, forKey: .nextResetTime)
91+ }
92+
93+ init(type: String, unit: Int, percentage: Int, nextResetTime: Int64? = nil) {
94+ self.type = type
95+ self.unit = unit
96+ self.percentage = percentage
97+ self.nextResetTime = nextResetTime
98+ }
99+}
100+
101+/// Response from `GET https://api.z.ai/api/biz/subscription/list`.
102+struct ZaiSubscriptionResponse: Decodable, Equatable {
103+ let code: Int
104+ let data: [ZaiSubscription]?
105+ let success: Bool
106+
107+ private enum CodingKeys: String, CodingKey {
108+ case code, data, success
109+ }
110+
111+ init(from decoder: Decoder) throws {
112+ let container = try decoder.container(keyedBy: CodingKeys.self)
113+ code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
114+ data = try container.decodeIfPresent([ZaiSubscription].self, forKey: .data)
115+ success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
116+ }
117+}
118+
119+struct ZaiSubscription: Decodable, Equatable {
120+ let productName: String?
121+ let status: String?
122+
123+ private enum CodingKeys: String, CodingKey {
124+ case productName, status
125+ }
126+
127+ init(from decoder: Decoder) throws {
128+ let container = try decoder.container(keyedBy: CodingKeys.self)
129+ productName = try container.decodeIfPresent(String.self, forKey: .productName)
130+ status = try container.decodeIfPresent(String.self, forKey: .status)
131+ }
132+
133+ init(productName: String? = nil, status: String? = nil) {
134+ self.productName = productName
135+ self.status = status
136+ }
137+}
138+
139+// MARK: - Errors
140+
141+/// Errors that can occur while fetching z.ai usage.
142+enum ZaiUsageError: LocalizedError, Equatable {
143+ /// No API key has been entered. Not an error the user needs to see โ€” the popover
144+ /// just leaves the section out, same as missing Claude credentials.
145+ case noAPIKey
146+
147+ /// The API key was rejected by z.ai.
148+ case invalidAPIKey
149+ case requestFailed(String)
150+ case decodingFailed(String)
151+
152+ var errorDescription: String? {
153+ switch self {
154+ case .noAPIKey:
155+ return nil
156+ case .invalidAPIKey:
157+ return "z.ai API key is invalid. Check your key at z.ai/manage-apikey/coding-plan/personal/my-plan"
158+ case .requestFailed(let message):
159+ return "z.ai request failed: \(message)"
160+ case .decodingFailed(let message):
161+ return "Failed to decode z.ai usage: \(message)"
162+ }
163+ }
164+}
165+
166+// MARK: - Protocol
167+
168+/// Protocol abstraction for fetching z.ai usage, enabling test injection.
169+protocol ZaiUsageChecking {
170+ func fetchUsage(apiKey: String) async throws -> ZaiUsageReport
171+}
172+
173+// MARK: - Client
174+
175+/// Client for z.ai's coding plan usage API, authenticating with an API key the user
176+/// enters in settings (stored in Keychain, separate from the Hyper key).
177+///
178+/// Fetches the quota/limit and subscription endpoints concurrently to get both usage
179+/// percentages and the plan tier in one round-trip. Retries transient failures with
180+/// exponential backoff, same pattern as `CreditsChecker` and `ClaudeUsageClient`.
181+actor ZaiUsageClient: ZaiUsageChecking {
182+ static let baseURL = URL(string: "https://api.z.ai/api")!
183+ static let quotaEndpoint = URL(string: "https://api.z.ai/api/monitor/usage/quota/limit")!
184+ static let subscriptionEndpoint = URL(string: "https://api.z.ai/api/biz/subscription/list")!
185+
186+ /// Retries *after* the initial attempt, so at most 3 requests per call.
187+ static let maxRetries = 2
188+
189+ /// URL error codes worth retrying: the request never reached a server that had an
190+ /// opinion about it, so the same request may well succeed a moment later.
191+ private static let retryableURLErrorCodes: Set<URLError.Code> = [
192+ .timedOut,
193+ .cannotConnectToHost,
194+ .cannotFindHost,
195+ .dnsLookupFailed,
196+ .networkConnectionLost,
197+ .notConnectedToInternet,
198+ .resourceUnavailable,
199+ .badServerResponse,
200+ ]
201+
202+ /// Client-error status codes that are nonetheless worth retrying: both are the
203+ /// server telling us to come back later, not that the request itself is wrong.
204+ private static let retryableStatusCodes: Set<Int> = [
205+ 408, // Request Timeout
206+ 429, // Too Many Requests
207+ ]
208+
209+ private let session: URLSession
210+ private let retryBaseDelay: Duration
211+
212+ /// - Parameters:
213+ /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
214+ /// one (2s, then 4s). Injectable so tests need not wait in real time.
215+ init(
216+ session: URLSession? = nil,
217+ retryBaseDelay: Duration = .seconds(2)
218+ ) {
219+ self.retryBaseDelay = retryBaseDelay
220+ if let session {
221+ self.session = session
222+ } else {
223+ let config = URLSessionConfiguration.default
224+ config.timeoutIntervalForRequest = 10
225+ config.timeoutIntervalForResource = 15
226+ self.session = URLSession(configuration: config)
227+ }
228+ }
229+
230+ /// Fetches current usage with the given API key.
231+ ///
232+ /// The quota and subscription endpoints are fetched concurrently; neither waits on
233+ /// the other. The subscription endpoint is best-effort โ€” if it fails, the plan
234+/// level from the quota response (`data.level`) is used instead.
235+ ///
236+ /// - Throws: `ZaiUsageError`. `.noAPIKey` means no key was provided, which callers
237+ /// should treat as "nothing to show" rather than as a failure.
238+ func fetchUsage(apiKey: String) async throws -> ZaiUsageReport {
239+ guard !apiKey.isEmpty else { throw ZaiUsageError.noAPIKey }
240+
241+ async let quotaOutcome = fetchQuota(apiKey: apiKey)
242+ async let subscriptionOutcome = fetchSubscription(apiKey: apiKey)
243+
244+ let quota = try await quotaOutcome
245+ let subscription = try? await subscriptionOutcome
246+
247+ // Extract percentages from the limits array.
248+ var fiveHourPercent: Int?
249+ var weeklyPercent: Int?
250+ var fiveHourResetsAt: Date?
251+ var weeklyResetsAt: Date?
252+
253+ for limit in quota?.limits ?? [] {
254+ switch limit.unit {
255+ case 3: // 5-hour window
256+ fiveHourPercent = limit.percentage
257+ fiveHourResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
258+ case 6: // weekly window
259+ weeklyPercent = limit.percentage
260+ weeklyResetsAt = limit.nextResetTime.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
261+ default:
262+ break
263+ }
264+ }
265+
266+ // Plan level: prefer the product name from the subscription, fall back to the
267+ // quota response's `level` field.
268+ var planLevel: String? = quota?.level
269+ if let sub = subscription?.data?.first(where: { $0.status == "VALID" }),
270+ let name = sub.productName {
271+ planLevel = name
272+ }
273+
274+ return ZaiUsageReport(
275+ fiveHourPercent: fiveHourPercent,
276+ weeklyPercent: weeklyPercent,
277+ fiveHourResetsAt: fiveHourResetsAt,
278+ weeklyResetsAt: weeklyResetsAt,
279+ planLevel: planLevel
280+ )
281+ }
282+
283+ // MARK: - Quota Request
284+
285+ private func fetchQuota(apiKey: String) async throws -> ZaiQuotaData? {
286+ var request = URLRequest(url: Self.quotaEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
287+ request.httpMethod = "GET"
288+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
289+ request.setValue("application/json", forHTTPHeaderField: "Accept")
290+
291+ let data = try await send(request)
292+
293+ do {
294+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: data)
295+ guard response.success else {
296+ throw ZaiUsageError.requestFailed(response.msg ?? "Unknown error")
297+ }
298+ return response.data
299+ } catch let error as ZaiUsageError {
300+ throw error
301+ } catch {
302+ throw ZaiUsageError.decodingFailed(error.localizedDescription)
303+ }
304+ }
305+
306+ // MARK: - Subscription Request
307+
308+ private func fetchSubscription(apiKey: String) async throws -> ZaiSubscriptionResponse {
309+ var request = URLRequest(url: Self.subscriptionEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
310+ request.httpMethod = "GET"
311+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
312+ request.setValue("application/json", forHTTPHeaderField: "Accept")
313+
314+ let data = try await send(request)
315+
316+ do {
317+ return try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: data)
318+ } catch {
319+ throw ZaiUsageError.decodingFailed(error.localizedDescription)
320+ }
321+ }
322+
323+ // MARK: - Transport
324+
325+ /// Sends a request, retrying transient failures with exponential backoff.
326+ private func send(_ request: URLRequest) async throws -> Data {
327+ var attempt = 0
328+
329+ while true {
330+ do {
331+ return try await performAttempt(request)
332+ } catch let failure as AttemptFailure {
333+ guard failure.isRetryable, attempt < Self.maxRetries else {
334+ throw failure.underlying
335+ }
336+ try await Task.sleep(for: retryBaseDelay * (1 << attempt))
337+ attempt += 1
338+ }
339+ }
340+ }
341+
342+ /// Performs a single request, tagging the failure with whether a retry could
343+ /// plausibly help.
344+ private func performAttempt(_ request: URLRequest) async throws -> Data {
345+ let data: Data
346+ let response: URLResponse
347+ do {
348+ (data, response) = try await session.data(for: request)
349+ } catch let urlError as URLError {
350+ throw AttemptFailure(
351+ underlying: .requestFailed(urlError.localizedDescription),
352+ isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
353+ )
354+ } catch {
355+ throw AttemptFailure(
356+ underlying: .requestFailed(error.localizedDescription),
357+ isRetryable: false
358+ )
359+ }
360+
361+ guard let httpResponse = response as? HTTPURLResponse else {
362+ throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
363+ }
364+
365+ switch httpResponse.statusCode {
366+ case 200...299:
367+ return data
368+ case 401:
369+ throw AttemptFailure(underlying: .invalidAPIKey, isRetryable: false)
370+ case 500...599:
371+ throw AttemptFailure(
372+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
373+ isRetryable: true
374+ )
375+ default:
376+ throw AttemptFailure(
377+ underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
378+ isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
379+ )
380+ }
381+ }
382+
383+ /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
384+ private struct AttemptFailure: Error {
385+ let underlying: ZaiUsageError
386+ let isRetryable: Bool
387+ }
388+}
UsageMenubarTests/StatusBarTextTests.swift+78โˆ’11
@@ -13,7 +13,8 @@ final class StatusBarTextTests: XCTestCase {
1313 func testBothServices() {
1414 let title = ViewModel.statusBarText(
1515 balance: 42, isLoading: false,
16โˆ’ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
16+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
17+ zaiFiveHourPercent: nil
1718 )
1819 XCTAssertEqual(title, "โšก42 ยท ๐Ÿ•62% ยท ๐Ÿ“…8%")
1920 }
@@ -21,7 +22,8 @@ final class StatusBarTextTests: XCTestCase {
2122 func testHyperOnly() {
2223 let title = ViewModel.statusBarText(
2324 balance: 42, isLoading: false,
24โˆ’ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
25+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
26+ zaiFiveHourPercent: nil
2527 )
2628 XCTAssertEqual(title, "โšก42")
2729 }
@@ -29,7 +31,8 @@ final class StatusBarTextTests: XCTestCase {
2931 func testClaudeOnly() {
3032 let title = ViewModel.statusBarText(
3133 balance: nil, isLoading: false,
32โˆ’ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
34+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
35+ zaiFiveHourPercent: nil
3336 )
3437 XCTAssertEqual(title, "๐Ÿ•62% ยท ๐Ÿ“…8%")
3538 }
@@ -37,7 +40,8 @@ final class StatusBarTextTests: XCTestCase {
3740 func testLoadingWithNothingToShow() {
3841 let title = ViewModel.statusBarText(
3942 balance: nil, isLoading: true,
40โˆ’ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
43+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
44+ zaiFiveHourPercent: nil
4145 )
4246 XCTAssertEqual(title, "โšกโ€ฆ")
4347 }
@@ -45,7 +49,8 @@ final class StatusBarTextTests: XCTestCase {
4549 func testNothingToShow() {
4650 let title = ViewModel.statusBarText(
4751 balance: nil, isLoading: false,
48โˆ’ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil
52+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
53+ zaiFiveHourPercent: nil
4954 )
5055 XCTAssertEqual(title, "โšก?")
5156 }
@@ -56,7 +61,8 @@ final class StatusBarTextTests: XCTestCase {
5661 func testLoadingKeepsAClaudePercentOnScreen() {
5762 let title = ViewModel.statusBarText(
5863 balance: nil, isLoading: true,
59โˆ’ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil
64+ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
65+ zaiFiveHourPercent: nil
6066 )
6167 XCTAssertEqual(title, "๐Ÿ•62%")
6268 }
@@ -64,7 +70,8 @@ final class StatusBarTextTests: XCTestCase {
6470 func testLoadingKeepsAStaleBalanceOnScreen() {
6571 let title = ViewModel.statusBarText(
6672 balance: 42, isLoading: true,
67โˆ’ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8
73+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
74+ zaiFiveHourPercent: nil
6875 )
6976 XCTAssertEqual(title, "โšก42 ยท ๐Ÿ•62% ยท ๐Ÿ“…8%")
7077 }
@@ -72,7 +79,8 @@ final class StatusBarTextTests: XCTestCase {
7279 func testZeroPercentIsShownRatherThanOmitted() {
7380 let title = ViewModel.statusBarText(
7481 balance: 42, isLoading: false,
75โˆ’ claudeFiveHourPercent: 0, claudeSevenDayPercent: 8
82+ claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
83+ zaiFiveHourPercent: nil
7684 )
7785 XCTAssertEqual(title, "โšก42 ยท ๐Ÿ“…8%")
7886 }
@@ -80,7 +88,8 @@ final class StatusBarTextTests: XCTestCase {
8088 func testBothClaudeWindowsZero() {
8189 let title = ViewModel.statusBarText(
8290 balance: 42, isLoading: false,
83โˆ’ claudeFiveHourPercent: 0, claudeSevenDayPercent: 0
91+ claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
92+ zaiFiveHourPercent: nil
8493 )
8594 XCTAssertEqual(title, "โšก42")
8695 }
@@ -88,7 +97,8 @@ final class StatusBarTextTests: XCTestCase {
8897 func testFiveHourOnly() {
8998 let title = ViewModel.statusBarText(
9099 balance: nil, isLoading: false,
91โˆ’ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil
100+ claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
101+ zaiFiveHourPercent: nil
92102 )
93103 XCTAssertEqual(title, "๐Ÿ•62%")
94104 }
@@ -96,11 +106,68 @@ final class StatusBarTextTests: XCTestCase {
96106 func testSevenDayOnly() {
97107 let title = ViewModel.statusBarText(
98108 balance: nil, isLoading: false,
99โˆ’ claudeFiveHourPercent: nil, claudeSevenDayPercent: 8
109+ claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
110+ zaiFiveHourPercent: nil
100111 )
101112 XCTAssertEqual(title, "๐Ÿ“…8%")
102113 }
103114
115+ // MARK: - z.ai in Title
116+
117+ func testZaiOnly() {
118+ let title = ViewModel.statusBarText(
119+ balance: nil, isLoading: false,
120+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
121+ zaiFiveHourPercent: 12
122+ )
123+ XCTAssertEqual(title, "๐Ÿค–12%")
124+ }
125+
126+ func testZaiWithHyper() {
127+ let title = ViewModel.statusBarText(
128+ balance: 42, isLoading: false,
129+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
130+ zaiFiveHourPercent: 12
131+ )
132+ XCTAssertEqual(title, "โšก42 ยท ๐Ÿค–12%")
133+ }
134+
135+ func testZaiWithClaude() {
136+ let title = ViewModel.statusBarText(
137+ balance: nil, isLoading: false,
138+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
139+ zaiFiveHourPercent: 12
140+ )
141+ XCTAssertEqual(title, "๐Ÿ•62% ยท ๐Ÿ“…8% ยท ๐Ÿค–12%")
142+ }
143+
144+ func testAllThreeServices() {
145+ let title = ViewModel.statusBarText(
146+ balance: 42, isLoading: false,
147+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
148+ zaiFiveHourPercent: 12
149+ )
150+ XCTAssertEqual(title, "โšก42 ยท ๐Ÿ•62% ยท ๐Ÿ“…8% ยท ๐Ÿค–12%")
151+ }
152+
153+ func testZaiZeroIsOmitted() {
154+ let title = ViewModel.statusBarText(
155+ balance: 42, isLoading: false,
156+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157+ zaiFiveHourPercent: 0
158+ )
159+ XCTAssertEqual(title, "โšก42")
160+ }
161+
162+ func testLoadingKeepsZaiPercentOnScreen() {
163+ let title = ViewModel.statusBarText(
164+ balance: nil, isLoading: true,
165+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
166+ zaiFiveHourPercent: 12
167+ )
168+ XCTAssertEqual(title, "๐Ÿค–12%")
169+ }
170+
104171 // MARK: - Claude Window Percentages
105172
106173 func testFiveHourPercentRounds() {
UsageMenubarTests/ZaiUsageClientTests.swiftnew file+242
@@ -0,0 +1,242 @@
1+import XCTest
2+@testable import UsageMenubar
3+
4+@MainActor
5+final class ZaiUsageClientTests: XCTestCase {
6+
7+ override func setUp() {
8+ super.setUp()
9+ StubURLProtocol.reset()
10+ }
11+
12+ override func tearDown() {
13+ StubURLProtocol.reset()
14+ super.tearDown()
15+ }
16+
17+ /// A client whose requests are served by `StubURLProtocol`, with a backoff short
18+ /// enough that the retry tests run in milliseconds rather than seconds.
19+ private func makeStubbedClient(retryBaseDelay: Duration = .milliseconds(1)) -> ZaiUsageClient {
20+ let config = URLSessionConfiguration.ephemeral
21+ config.protocolClasses = [StubURLProtocol.self]
22+ return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
23+ }
24+
25+ // MARK: - Decoding
26+
27+ func testDecodeQuotaResponse() throws {
28+ let json = """
29+ {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
30+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
31+ {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
32+ ],"level":"max"}}
33+ """.data(using: .utf8)!
34+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
35+ XCTAssertTrue(response.success)
36+ XCTAssertEqual(response.code, 200)
37+ XCTAssertEqual(response.data?.level, "max")
38+ XCTAssertEqual(response.data?.limits?.count, 2)
39+ XCTAssertEqual(response.data?.limits?.first?.percentage, 12)
40+ XCTAssertEqual(response.data?.limits?.first?.unit, 3)
41+ }
42+
43+ func testDecodeQuotaResponseWithFractionalPercentage() throws {
44+ let json = """
45+ {"code":200,"success":true,"data":{"limits":[
46+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12.6,"nextResetTime":1784278018952}
47+ ],"level":"pro"}}
48+ """.data(using: .utf8)!
49+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
50+ XCTAssertEqual(response.data?.limits?.first?.percentage, 13)
51+ }
52+
53+ func testDecodeQuotaResponseWithMissingFields() throws {
54+ let json = """
55+ {"code":200,"success":true,"data":{"limits":[],"level":null}}
56+ """.data(using: .utf8)!
57+ let response = try JSONDecoder().decode(ZaiQuotaResponse.self, from: json)
58+ XCTAssertEqual(response.data?.limits?.count, 0)
59+ XCTAssertNil(response.data?.level)
60+ }
61+
62+ func testDecodeSubscriptionResponse() throws {
63+ let json = """
64+ {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
65+ """.data(using: .utf8)!
66+ let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
67+ XCTAssertEqual(response.data?.first?.productName, "GLM Coding Max")
68+ XCTAssertEqual(response.data?.first?.status, "VALID")
69+ }
70+
71+ func testDecodeEmptySubscriptionResponse() throws {
72+ let json = """
73+ {"code":200,"data":[],"success":true}
74+ """.data(using: .utf8)!
75+ let response = try JSONDecoder().decode(ZaiSubscriptionResponse.self, from: json)
76+ XCTAssertEqual(response.data?.count, 0)
77+ }
78+
79+ // MARK: - Fetch
80+
81+ /// Helper: enqueues a quota response and a subscription response. The two endpoints
82+ /// are fetched concurrently, so the order they're enqueued in is the order they'll
83+ /// be dequeued โ€” but which request gets which response depends on which arrives first
84+ /// at the stub. Since the stub dequeues in FIFO order, we enqueue quota first (it's
85+ /// the one whose data matters), then subscription.
86+ private func enqueueQuotaAndSubscription(
87+ quotaStatus: Int = 200,
88+ quotaJSON: Data,
89+ subscriptionStatus: Int = 200,
90+ subscriptionJSON: Data
91+ ) {
92+ StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON))
93+ StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON))
94+ }
95+
96+ func testFetchUsageSuccess() async throws {
97+ let quotaJSON = """
98+ {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
99+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12,"nextResetTime":1784278018952},
100+ {"type":"TOKENS_LIMIT","unit":6,"percentage":3,"nextResetTime":1784286332993}
101+ ],"level":"max"}}
102+ """.data(using: .utf8)!
103+ let subscriptionJSON = """
104+ {"code":200,"data":[{"productName":"GLM Coding Max","status":"VALID"}],"success":true}
105+ """.data(using: .utf8)!
106+
107+ enqueueQuotaAndSubscription(quotaJSON: quotaJSON, subscriptionJSON: subscriptionJSON)
108+
109+ let client = makeStubbedClient()
110+ let report = try await client.fetchUsage(apiKey: "test-key")
111+
112+ XCTAssertEqual(report.fiveHourPercent, 12)
113+ XCTAssertEqual(report.weeklyPercent, 3)
114+ XCTAssertNotNil(report.fiveHourResetsAt)
115+ XCTAssertNotNil(report.weeklyResetsAt)
116+ }
117+
118+ func testFetchUsageNoKey() async {
119+ let client = makeStubbedClient()
120+ do {
121+ _ = try await client.fetchUsage(apiKey: "")
122+ XCTFail("Expected noAPIKey error")
123+ } catch ZaiUsageError.noAPIKey {
124+ // Expected
125+ } catch {
126+ XCTFail("Unexpected error: \(error)")
127+ }
128+
129+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
130+ }
131+
132+ func testFetchUsageInvalidKey() async {
133+ // Both endpoints will 401 โ€” the quota endpoint's 401 is what surfaces.
134+ let errorJSON = """
135+ {"code":401,"msg":"token expired or incorrect","success":false}
136+ """.data(using: .utf8)!
137+ StubURLProtocol.enqueue(.status(401, errorJSON))
138+ StubURLProtocol.enqueue(.status(401, errorJSON))
139+
140+ let client = makeStubbedClient()
141+ do {
142+ _ = try await client.fetchUsage(apiKey: "bad-key")
143+ XCTFail("Expected invalidAPIKey error")
144+ } catch ZaiUsageError.invalidAPIKey {
145+ // Expected
146+ } catch {
147+ XCTFail("Unexpected error: \(error)")
148+ }
149+ }
150+
151+ // MARK: - Retry
152+
153+ func testRetriesOnServerErrorThenSucceeds() async throws {
154+ let quotaJSON = """
155+ {"code":200,"success":true,"data":{"limits":[
156+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
157+ ],"level":"max"}}
158+ """.data(using: .utf8)!
159+ let subscriptionJSON = """
160+ {"code":200,"data":[],"success":true}
161+ """.data(using: .utf8)!
162+
163+ // Quota: 500 then 200 (retry). Subscription: 200 (first try).
164+ // The order is: quota(500), subscription(200), quota(200 on retry).
165+ StubURLProtocol.enqueue(.status(500, Data()))
166+ StubURLProtocol.enqueue(.status(200, subscriptionJSON))
167+ StubURLProtocol.enqueue(.status(200, quotaJSON))
168+
169+ let client = makeStubbedClient()
170+ let report = try await client.fetchUsage(apiKey: "test-key")
171+ XCTAssertEqual(report.fiveHourPercent, 12)
172+ }
173+
174+ func testRetriesOnRateLimitThenSucceeds() async throws {
175+ let quotaJSON = """
176+ {"code":200,"success":true,"data":{"limits":[
177+ {"type":"TOKENS_LIMIT","unit":3,"percentage":12}
178+ ],"level":"max"}}
179+ """.data(using: .utf8)!
180+ let subscriptionJSON = """
181+ {"code":200,"data":[],"success":true}
182+ """.data(using: .utf8)!
183+
184+ StubURLProtocol.enqueue(.status(429, Data()))
185+ StubURLProtocol.enqueue(.status(200, subscriptionJSON))
186+ StubURLProtocol.enqueue(.status(200, quotaJSON))
187+
188+ let client = makeStubbedClient()
189+ let report = try await client.fetchUsage(apiKey: "test-key")
190+ XCTAssertEqual(report.fiveHourPercent, 12)
191+ }
192+
193+ func testDoesNotRetryOn401() async {
194+ let errorJSON = """
195+ {"code":401,"msg":"token expired or incorrect","success":false}
196+ """.data(using: .utf8)!
197+ StubURLProtocol.enqueue(.status(401, errorJSON))
198+ StubURLProtocol.enqueue(.status(401, errorJSON))
199+
200+ let client = makeStubbedClient()
201+ do {
202+ _ = try await client.fetchUsage(apiKey: "bad-key")
203+ XCTFail("Expected invalidAPIKey error")
204+ } catch ZaiUsageError.invalidAPIKey {
205+ // Expected โ€” should not retry
206+ } catch {
207+ XCTFail("Unexpected error: \(error)")
208+ }
209+ }
210+
211+ // MARK: - ZaiUsageReport
212+
213+ func testEmptyReportIsEmpty() {
214+ let report = ZaiUsageReport(
215+ fiveHourPercent: nil,
216+ weeklyPercent: nil,
217+ fiveHourResetsAt: nil,
218+ weeklyResetsAt: nil,
219+ planLevel: nil
220+ )
221+ XCTAssertTrue(report.isEmpty)
222+ }
223+
224+ func testNonEmptyReportIsNotEmpty() {
225+ let report = ZaiUsageReport(
226+ fiveHourPercent: 12,
227+ weeklyPercent: nil,
228+ fiveHourResetsAt: nil,
229+ weeklyResetsAt: nil,
230+ planLevel: nil
231+ )
232+ XCTAssertFalse(report.isEmpty)
233+ }
234+
235+ // MARK: - Protocol conformance
236+
237+ func testZaiUsageClientConformsToProtocol() {
238+ let checker: ZaiUsageChecking = ZaiUsageClient()
239+ // If this compiles, ZaiUsageClient conforms to ZaiUsageChecking.
240+ XCTAssertNotNil(checker)
241+ }
242+}