Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,7 @@ extension UsageMenuCardView.Model {
let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil
let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit)
let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit)
let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit)
let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot)
if input.provider == .codex, let codexProjection = input.codexProjection {
metrics.append(contentsOf: Self.codexRateMetrics(
Expand Down Expand Up @@ -984,6 +985,9 @@ extension UsageMenuCardView.Model {
{
tertiaryDetailText = detail
}
if input.provider == .zai, let detail = zaiSessionDetail {
tertiaryDetailText = detail
}
// Perplexity purchased credits don't reset; show balance without "Resets" prefix.
let opusResetText: String? = input.provider == .perplexity
? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ public enum ZaiProviderDescriptor {
displayName: "z.ai",
sessionLabel: "Tokens",
weeklyLabel: "MCP",
opusLabel: nil,
supportsOpus: false,
opusLabel: "5-hour",
supportsOpus: true,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show z.ai usage",
Expand Down
39 changes: 34 additions & 5 deletions Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public enum ZaiLimitUnit: Int, Sendable {
case days = 1
case hours = 3
case minutes = 5
case weeks = 6
}

/// A single limit entry from the z.ai API
Expand Down Expand Up @@ -69,6 +70,8 @@ extension ZaiLimitEntry {
return self.number * 60
case .days:
return self.number * 24 * 60
case .weeks:
return self.number * 7 * 24 * 60
case .unknown:
return nil
}
Expand All @@ -80,6 +83,7 @@ extension ZaiLimitEntry {
case .minutes: "minute"
case .hours: "hour"
case .days: "day"
case .weeks: "week"
case .unknown: nil
}
guard let unitLabel else { return nil }
Expand Down Expand Up @@ -129,12 +133,21 @@ public struct ZaiUsageDetail: Sendable, Codable {
/// Complete z.ai usage response
public struct ZaiUsageSnapshot: Sendable {
public let tokenLimit: ZaiLimitEntry?
/// Shorter-window TOKENS_LIMIT (e.g. 5-hour), present only when the API returns two TOKENS_LIMIT entries.
public let sessionTokenLimit: ZaiLimitEntry?
public let timeLimit: ZaiLimitEntry?
public let planName: String?
public let updatedAt: Date

public init(tokenLimit: ZaiLimitEntry?, timeLimit: ZaiLimitEntry?, planName: String?, updatedAt: Date) {
public init(
tokenLimit: ZaiLimitEntry?,
sessionTokenLimit: ZaiLimitEntry? = nil,
timeLimit: ZaiLimitEntry?,
planName: String?,
updatedAt: Date)
{
self.tokenLimit = tokenLimit
self.sessionTokenLimit = sessionTokenLimit
self.timeLimit = timeLimit
self.planName = planName
self.updatedAt = updatedAt
Expand All @@ -150,13 +163,13 @@ extension ZaiUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
let primaryLimit = self.tokenLimit ?? self.timeLimit
let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil

let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow(
usedPercent: 0,
windowMinutes: nil,
resetsAt: nil,
resetDescription: nil)
let secondary = secondaryLimit.map { Self.rateWindow(for: $0) }
let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) }

let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
let loginMethod = (planName?.isEmpty ?? true) ? nil : planName
Expand All @@ -168,7 +181,7 @@ extension ZaiUsageSnapshot {
return UsageSnapshot(
primary: primary,
secondary: secondary,
tertiary: nil,
tertiary: tertiary,
providerCost: nil,
zaiUsage: self,
updatedAt: self.updatedAt,
Expand Down Expand Up @@ -364,22 +377,38 @@ public struct ZaiUsageFetcher: Sendable {
throw ZaiUsageError.parseFailed("Missing data")
}

var tokenLimit: ZaiLimitEntry?
var tokenLimits: [ZaiLimitEntry] = []
var timeLimit: ZaiLimitEntry?

for limit in responseData.limits {
if let entry = limit.toLimitEntry() {
switch entry.type {
case .tokensLimit:
tokenLimit = entry
tokenLimits.append(entry)
case .timeLimit:
timeLimit = entry
}
}
}

// Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary),
// longest → tokenLimit (primary).
let tokenLimit: ZaiLimitEntry?
let sessionTokenLimit: ZaiLimitEntry?
if tokenLimits.count >= 2 {
let sorted = tokenLimits.sorted {
($0.windowMinutes ?? Int.max) < ($1.windowMinutes ?? Int.max)
}
sessionTokenLimit = sorted.first
tokenLimit = sorted.last
} else {
tokenLimit = tokenLimits.first
sessionTokenLimit = nil
}

return ZaiUsageSnapshot(
tokenLimit: tokenLimit,
sessionTokenLimit: sessionTokenLimit,
timeLimit: timeLimit,
planName: responseData.planName,
updatedAt: Date())
Expand Down
23 changes: 23 additions & 0 deletions Tests/CodexBarTests/CLISnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,4 +425,27 @@ struct CLISnapshotTests {
#expect(!output.contains("\u{001B}["))
#expect(output.contains("Status: Operational – Operational"))
}

@Test
func `renders 5-hour tertiary row for zai`() {
let snap = UsageSnapshot(
primary: .init(usedPercent: 9, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
secondary: .init(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
tertiary: .init(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
updatedAt: Date(timeIntervalSince1970: 0))

let output = CLIRenderer.renderText(
provider: .zai,
snapshot: snap,
credits: nil,
context: RenderContext(
header: "z.ai 0.0.0 (zai)",
status: nil,
useColor: false,
resetStyle: .absolute))

#expect(output.contains("5-hour:"))
#expect(output.contains("Tokens:"))
#expect(output.contains("MCP:"))
}
}
50 changes: 50 additions & 0 deletions Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,56 @@ struct CodexPresentationCharacterizationTests {
#expect(store.codexCookieCacheScopeForOpenAIWeb() == nil)
}

@Test
func `zai menu descriptor includes Tokens MCP and 5-hour rows`() {
let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-zai-three-quota")
settings.statusChecksEnabled = false

let fetcher = UsageFetcher(environment: [:])
let store = UsageStore(
fetcher: fetcher,
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
startupBehavior: .testing)
store._setSnapshotForTesting(
UsageSnapshot(
primary: RateWindow(
usedPercent: 9,
windowMinutes: 10080,
resetsAt: nil,
resetDescription: nil),
secondary: RateWindow(
usedPercent: 50,
windowMinutes: nil,
resetsAt: nil,
resetDescription: nil),
tertiary: RateWindow(
usedPercent: 25,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil),
updatedAt: Date(),
identity: ProviderIdentitySnapshot(
providerID: .zai,
accountEmail: nil,
accountOrganization: nil,
loginMethod: "pro")),
provider: .zai)

let descriptor = MenuDescriptor.build(
provider: .zai,
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updateReady: false,
includeContextualActions: false)

let lines = self.textLines(from: descriptor)
#expect(lines.contains(where: { $0.hasPrefix("Tokens:") }))
#expect(lines.contains(where: { $0.hasPrefix("MCP:") }))
#expect(lines.contains(where: { $0.hasPrefix("5-hour:") }))
}

private func makeSettingsStore(suite: String) -> SettingsStore {
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
Expand Down
70 changes: 70 additions & 0 deletions Tests/CodexBarTests/ZaiMenuCardTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

struct ZaiMenuCardTests {
@Test
func `zai metrics titles are Tokens MCP and 5-hour when session token limit present`() throws {
let now = Date()
let zai = ZaiUsageSnapshot(
tokenLimit: ZaiLimitEntry(
type: .tokensLimit,
unit: .weeks,
number: 1,
usage: nil,
currentValue: nil,
remaining: nil,
percentage: 9,
usageDetails: [],
nextResetTime: nil),
sessionTokenLimit: ZaiLimitEntry(
type: .tokensLimit,
unit: .hours,
number: 5,
usage: 1000,
currentValue: 750,
remaining: 250,
percentage: 25,
usageDetails: [],
nextResetTime: nil),
timeLimit: ZaiLimitEntry(
type: .timeLimit,
unit: .minutes,
number: 1,
usage: 100,
currentValue: 50,
remaining: 50,
percentage: 50,
usageDetails: [],
nextResetTime: nil),
planName: "pro",
updatedAt: now)
let snapshot = zai.toUsageSnapshot()
let metadata = try #require(ProviderDefaults.metadata[.zai])

let model = UsageMenuCardView.Model.make(.init(
provider: .zai,
metadata: metadata,
snapshot: snapshot,
credits: nil,
creditsError: nil,
dashboard: nil,
dashboardError: nil,
tokenSnapshot: nil,
tokenError: nil,
account: AccountInfo(email: nil, plan: nil),
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
resetTimeDisplayStyle: .countdown,
tokenCostUsageEnabled: false,
showOptionalCreditsAndExtraUsage: true,
hidePersonalInfo: false,
now: now))

#expect(model.metrics.map(\.title) == ["Tokens", "MCP", "5-hour"])
let tertiary = try #require(model.metrics.first(where: { $0.title == "5-hour" }))
#expect(tertiary.detailText == "750 / 1K (250 remaining)")
}
}
Loading