diff --git a/.gitignore b/.gitignore index 462a66f97a..a49f53a21d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ debug_*.swift .codex/environments/ .swiftpm-cache/ .tmp-clang/ +.worktrees/ __pycache__/ # Debug/analysis docs diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index dbe044f6fd..f14d6841c7 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -447,13 +447,57 @@ public struct AntigravityStatusSnapshot: Sendable { } private static func rateWindow(for quota: AntigravityModelQuota) -> RateWindow { - RateWindow( + let windowMinutes = self.windowMinutes(forModelQuota: quota) + return RateWindow( usedPercent: 100 - quota.remainingPercent, - windowMinutes: nil, + windowMinutes: windowMinutes, resetsAt: quota.resetTime, resetDescription: quota.resetDescription) } + private static func windowMinutes(forModelQuota quota: AntigravityModelQuota) -> Int? { + var candidates: Set = [] + for rawValue in [quota.modelId, quota.label, quota.resetDescription ?? ""] { + let normalized = rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + guard !normalized.isEmpty else { continue } + candidates.insert(normalized) + for token in normalized.components(separatedBy: CharacterSet.alphanumerics.inverted) where !token.isEmpty { + candidates.insert(token) + } + // Match compound aliases embedded in hyphenated labels, e.g. "gemini-pro-7-day" → "7-day" + var normalizedVariants = [normalized] + if normalized.hasSuffix(" limit") { + normalizedVariants.append(String(normalized.dropLast(" limit".count))) + } + for variant in normalizedVariants { + for alias in Self.sessionCadenceAliases.union(Self.weeklyCadenceAliases) { + if variant == alias || variant.hasSuffix("-\(alias)") { + candidates.insert(alias) + } + } + } + } + if !candidates.isDisjoint(with: Self.sessionCadenceAliases) { + return 300 + } + if !candidates.isDisjoint(with: Self.weeklyCadenceAliases) { + return 10080 + } + return nil + } + + private static let weeklyCadenceAliases: Set = [ + "weekly", + "7-day", + "7d", + "7-days", + "seven-day", + "seven day", + ] + private static func modelOrderPrecedes( _ lhs: AntigravityNormalizedModel, _ rhs: AntigravityNormalizedModel) -> Bool diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index a56f801a2a..cb1e41ace7 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -42,6 +42,8 @@ public struct GrokUsageSnapshot: Sendable { } else if let webBilling, let percent = webBilling.usedPercent { + // Do not infer the full quota cadence from the remaining time until reset; a + // monthly window near its reset would otherwise be misclassified as weekly. primary = RateWindow( usedPercent: percent, windowMinutes: nil, diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index 029f257374..81e6eb6ab0 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -126,7 +126,7 @@ extension KimiUsageSnapshot { guard let ratio = balance.amountUsedRatio, ratio.isFinite else { return nil } let window = RateWindow( usedPercent: Self.clampedPercent(ratio * 100), - windowMinutes: nil, // Calendar-month duration varies; do not fabricate a fixed 30-day window. + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, resetsAt: Self.parseDate(balance.expireTime), resetDescription: nil) return NamedRateWindow(id: "kimi-monthly", title: "Monthly", window: window) diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 39380095bf..27a9229f8a 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -224,9 +224,18 @@ extension ZaiUsageSnapshot { } private static func rateWindow(for limit: ZaiLimitEntry) -> RateWindow { - RateWindow( + let windowMinutes: Int? = if limit.isMCPMonthlyMarker { + ProviderPaceCapability.monthlyWindowSentinelMinutes + } else if limit.type == .timeLimit, let minutes = limit.windowMinutes { + minutes + } else if limit.type == .timeLimit { + ProviderPaceCapability.monthlyWindowSentinelMinutes + } else { + limit.windowMinutes + } + return RateWindow( usedPercent: limit.usedPercent, - windowMinutes: limit.type == .tokensLimit ? limit.windowMinutes : nil, + windowMinutes: windowMinutes, resetsAt: limit.nextResetTime, resetDescription: self.resetDescription(for: limit)) } diff --git a/Tests/CodexBarTests/AntigravityWindowMinutesTests.swift b/Tests/CodexBarTests/AntigravityWindowMinutesTests.swift new file mode 100644 index 0000000000..814dcf1de4 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityWindowMinutesTests.swift @@ -0,0 +1,110 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct AntigravityWindowMinutesTests { + @Test + func `antigravity model quota derives windowMinutes from reset description`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude 3.5 Sonnet", + modelId: "claude-3-5-sonnet", + remainingFraction: 0.8, + resetTime: Date(), + resetDescription: "5-hour session reset"), + AntigravityModelQuota( + label: "Gemini 2.5 Pro", + modelId: "gemini-2-5-pro", + remainingFraction: 0.5, + resetTime: Date(), + resetDescription: "Weekly reset"), + ], + accountEmail: "test@example.com", + accountPlan: "Pro", + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == 10080) + #expect(usage.secondary?.windowMinutes == 300) + } + + @Test + func `antigravity model quota derives windowMinutes from model label when reset description is nil`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 2.5 Pro (Weekly)", + modelId: "gemini-2-5-pro", + remainingFraction: 0.5, + resetTime: Date(), + resetDescription: nil), + AntigravityModelQuota( + label: "Claude 3.5 Sonnet (5-hour session)", + modelId: "claude-3-5-sonnet", + remainingFraction: 0.8, + resetTime: Date(), + resetDescription: nil), + ], + accountEmail: "test@example.com", + accountPlan: "Pro", + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == 10080) + #expect(usage.secondary?.windowMinutes == 300) + } + + @Test + func `antigravity model quota derives windowMinutes from compound cadence aliases`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 2.5 Pro", + modelId: "gemini-2-5-pro", + remainingFraction: 0.5, + resetTime: Date(), + resetDescription: "7-day limit"), + AntigravityModelQuota( + label: "Claude 3.5 Sonnet", + modelId: "claude-3-5-sonnet", + remainingFraction: 0.8, + resetTime: Date(), + resetDescription: "five-hour session"), + ], + accountEmail: "test@example.com", + accountPlan: "Pro", + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == 10080) + #expect(usage.secondary?.windowMinutes == 300) + } + + @Test + func `antigravity model quota derives windowMinutes from hyphenated model id with compound alias`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini Pro", + modelId: "gemini-pro-7-day", + remainingFraction: 0.5, + resetTime: Date(), + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet", + modelId: "claude-sonnet-five-hour", + remainingFraction: 0.8, + resetTime: Date(), + resetDescription: nil), + ], + accountEmail: "test@example.com", + accountPlan: "Pro", + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == 10080) + #expect(usage.secondary?.windowMinutes == 300) + } +} diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index afe19ac254..d0684be3a0 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -920,6 +920,24 @@ extension GrokWebBillingFetcherTests { #expect(usage.loginMethod(for: .grok) == "SuperGrok") } + @Test + func `usage snapshot does not classify a monthly reset near its end as weekly`() { + // A monthly quota with six days left must not be reported as a weekly window. + let snapshot = GrokUsageSnapshot( + billing: nil, + webBilling: GrokWebBillingSnapshot( + usedPercent: 12, + resetsAt: Date(timeIntervalSince1970: 1_799_000_000 + 6 * 86400)), + credentials: Self.credentials, + localSummary: nil, + cliVersion: nil, + updatedAt: Date(timeIntervalSince1970: 1_799_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.windowMinutes == nil) + } + private static let credentials = GrokCredentials( accessToken: "token-123", refreshToken: "refresh-123", diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index 912ecf7c48..376cba1cd8 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -1023,7 +1023,7 @@ struct KimiUsageSnapshotConversionTests { #expect(monthly.id == "kimi-monthly") #expect(monthly.title == "Monthly") #expect(monthly.window.usedPercent == 100) - #expect(monthly.window.windowMinutes == nil) + #expect(monthly.window.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) #expect(monthly.window.resetsAt == Self.date("2026-07-23T00:00:00Z")) } diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index ea46854179..2bdbe2acc7 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -195,6 +195,55 @@ struct ZaiUsageSnapshotTests { #expect(usage.primary?.usedPercent == 25) } + + @Test + func `time limit with explicit duration preserves windowMinutes instead of monthly sentinel`() { + let reset = Date(timeIntervalSince1970: 123) + let timeLimit = ZaiLimitEntry( + type: .timeLimit, + unit: .hours, + number: 5, + usage: 100, + currentValue: 20, + remaining: 80, + percentage: 25, + usageDetails: [], + nextResetTime: reset) + let snapshot = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: timeLimit, + planName: nil, + updatedAt: reset) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetDescription == "5 hours window") + } + + @Test + func `time limit without explicit duration falls back to monthly sentinel`() { + let reset = Date(timeIntervalSince1970: 123) + let timeLimit = ZaiLimitEntry( + type: .timeLimit, + unit: .days, + number: 0, + usage: 100, + currentValue: 20, + remaining: 80, + percentage: 25, + usageDetails: [], + nextResetTime: reset) + let snapshot = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: timeLimit, + planName: nil, + updatedAt: reset) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) + } } struct ZaiUsageParsingTests { @@ -253,7 +302,7 @@ struct ZaiUsageParsingTests { #expect(snapshot.tokenLimit?.percentage == 34.0) let usage = snapshot.toUsageSnapshot() - #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) #expect(usage.secondary?.resetDescription == "Monthly") } @@ -292,7 +341,7 @@ struct ZaiUsageParsingTests { let usage = snapshot.toUsageSnapshot() #expect(snapshot.timeLimit?.windowDescription == "1 minute") - #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) #expect(usage.secondary?.resetDescription == "Monthly") }