-
Notifications
You must be signed in to change notification settings - Fork 5
fix: support CREDIT_LIMIT quotas for Z.AI Coding Plan #150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5d16c14
4516b42
a43e1f7
437835e
2eeee24
e437791
3bbf266
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,15 +11,15 @@ | |
| case usage | ||
| } | ||
|
|
||
| private enum UsageDisplayWindowPriority: Int, CaseIterable { | ||
| enum UsageDisplayWindowPriority: Int, CaseIterable { | ||
| case weekly = 0 | ||
| case monthly = 1 | ||
| case daily = 2 | ||
| case hourly = 3 | ||
| case fallback = 4 | ||
| } | ||
|
|
||
| private struct UsagePercentCandidate { | ||
| struct UsagePercentCandidate { | ||
| let percent: Double | ||
| let priority: UsageDisplayWindowPriority | ||
| } | ||
|
|
@@ -917,20 +917,20 @@ | |
| return ProviderIdentifier.allCases.first(where: { isProviderEnabled($0) }) | ||
| } | ||
|
|
||
| private func normalizedUsagePercent(_ percent: Double?) -> Double? { | ||
| private static func normalizedUsagePercent(_ percent: Double?) -> Double? { | ||
| guard let percent, percent.isFinite else { return nil } | ||
| return min(max(percent, 0), 999) | ||
| } | ||
|
|
||
| private func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? { | ||
| private static func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? { | ||
| guard let details else { return nil } | ||
| if let limit = details.limit, limit > 0, let used = details.dailyUsage { | ||
| return (used / limit) * 100.0 | ||
| } | ||
| return details.dailyUsage | ||
| } | ||
|
|
||
| private func priorityForWindowHours( | ||
| private static func priorityForWindowHours( | ||
| _ hours: Int?, | ||
| fallback: UsageDisplayWindowPriority | ||
| ) -> UsageDisplayWindowPriority { | ||
|
|
@@ -941,7 +941,7 @@ | |
| return .hourly | ||
| } | ||
|
|
||
| private func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? { | ||
| private static func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? { | ||
| guard let details else { return nil } | ||
|
|
||
| let configuredPlan = SubscriptionSettingsManager.shared.getPlan(for: .chutes) | ||
|
|
@@ -959,14 +959,27 @@ | |
| return details.chutesMonthlyValueUsedPercent | ||
| } | ||
|
|
||
| private func usagePercentCandidates( | ||
| /// Window percentages shown on the Z.AI top-level quota/provider row. | ||
| /// Unlike the status-bar candidate list (priority-ordered), the top-level | ||
| /// row shows every active window side by side, so the Lite weekly window | ||
| /// must be included here too — omitting it makes the row diverge from the | ||
| /// usage windows (5h session, weekly, MCP monthly). | ||
| private static func zaiCodingPlanTopLevelPercents(details: DetailedUsage?) -> [Double] { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Z.AI windows are still enumerated separately for the top-level row, status-bar candidate selection, and change detection. Define one shared window descriptor with its field and display priority, then derive each ordered list from it; otherwise adding the next window needs synchronized edits and can silently make one path disagree with the others.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The top-level row now has its own Z.AI window list, while candidate selection and change detection still enumerate the fields separately. Define one ordered window descriptor and derive these consumer lists from it; otherwise the next quota window needs synchronized edits and one path can silently disagree. |
||
| [ | ||
| details?.tokenUsagePercent, | ||
| details?.weeklyUsagePercent, | ||
| details?.mcpUsagePercent | ||
| ].compactMap { $0 } | ||
| } | ||
|
|
||
| static func usagePercentCandidates( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| identifier: ProviderIdentifier, | ||
| usage: ProviderUsage, | ||
| details: DetailedUsage? | ||
| ) -> [UsagePercentCandidate] { | ||
| var candidates: [UsagePercentCandidate] = [] | ||
| func add(_ percent: Double?, priority: UsageDisplayWindowPriority) { | ||
| guard let normalized = normalizedUsagePercent(percent) else { return } | ||
| guard let normalized = Self.normalizedUsagePercent(percent) else { return } | ||
| candidates.append(UsagePercentCandidate(percent: normalized, priority: priority)) | ||
| } | ||
|
|
||
|
|
@@ -995,19 +1008,19 @@ | |
| case .codex: | ||
| add( | ||
| details?.secondaryUsage, | ||
| priority: priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly) | ||
| priority: Self.priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly) | ||
| ) | ||
| add( | ||
| details?.sparkSecondaryUsage, | ||
| priority: priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly) | ||
| priority: Self.priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly) | ||
| ) | ||
| add( | ||
| dailyPercentFromDetails(details), | ||
| priority: priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily) | ||
| Self.dailyPercentFromDetails(details), | ||
| priority: Self.priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily) | ||
| ) | ||
| add( | ||
| details?.sparkUsage, | ||
| priority: priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly) | ||
| priority: Self.priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly) | ||
| ) | ||
| case .commandCode: | ||
| add(usage.usagePercentage, priority: .monthly) | ||
|
|
@@ -1024,11 +1037,12 @@ | |
| case .zaiCodingPlan: | ||
| add(details?.mcpUsagePercent, priority: .monthly) | ||
| add(details?.tokenUsagePercent, priority: .hourly) | ||
| add(details?.weeklyUsagePercent, priority: .weekly) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This adds the weekly window to status-bar candidate selection, but the top-level Z.AI quota row still formats |
||
| case .nanoGpt: | ||
| add(details?.sevenDayUsage, priority: .weekly) | ||
| case .chutes: | ||
| add(chutesMonthlyPercentFromDetails(details), priority: .monthly) | ||
| add(dailyPercentFromDetails(details), priority: .daily) | ||
| add(Self.chutesMonthlyPercentFromDetails(details), priority: .monthly) | ||
| add(Self.dailyPercentFromDetails(details), priority: .daily) | ||
| case .synthetic: | ||
| add(details?.fiveHourUsage, priority: .hourly) | ||
| case .tavilySearch, .braveSearch: | ||
|
|
@@ -1046,7 +1060,7 @@ | |
| usage: ProviderUsage, | ||
| details: DetailedUsage? | ||
| ) -> Double? { | ||
| let candidates = usagePercentCandidates(identifier: identifier, usage: usage, details: details) | ||
| let candidates = Self.usagePercentCandidates(identifier: identifier, usage: usage, details: details) | ||
| guard let selectedPriority = candidates.map(\.priority.rawValue).min() else { | ||
| return nil | ||
| } | ||
|
|
@@ -1068,7 +1082,7 @@ | |
| // Main result candidates | ||
| if case .quotaBased = result.usage { | ||
| allCandidates.append(contentsOf: | ||
| usagePercentCandidates(identifier: identifier, usage: result.usage, details: result.details) | ||
| Self.usagePercentCandidates(identifier: identifier, usage: result.usage, details: result.details) | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -1077,15 +1091,15 @@ | |
| for account in accounts { | ||
| guard case .quotaBased = account.usage else { continue } | ||
| allCandidates.append(contentsOf: | ||
| usagePercentCandidates(identifier: identifier, usage: account.usage, details: account.details) | ||
| Self.usagePercentCandidates(identifier: identifier, usage: account.usage, details: account.details) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // Gemini CLI special case: add as fallback priority since these don't have window metadata | ||
| if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts { | ||
| for account in geminiAccounts { | ||
| if let normalized = normalizedUsagePercent(100.0 - account.remainingPercentage) { | ||
| if let normalized = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) { | ||
| allCandidates.append(UsagePercentCandidate(percent: normalized, priority: .fallback)) | ||
| } | ||
| } | ||
|
|
@@ -1103,12 +1117,12 @@ | |
| .max() | ||
| } | ||
|
|
||
| private func usedPercentsForChangeDetection(identifier: ProviderIdentifier, result: ProviderResult) -> [Double] { | ||
| static func usedPercentsForChangeDetection(identifier: ProviderIdentifier, result: ProviderResult) -> [Double] { | ||
| var usedPercents: [Double] = [] | ||
|
|
||
| func appendMetrics(usage: ProviderUsage, details: DetailedUsage?) { | ||
| guard case .quotaBased = usage else { return } | ||
| if let percent = normalizedUsagePercent(usage.usagePercentage) { | ||
| if let percent = Self.normalizedUsagePercent(usage.usagePercentage) { | ||
| usedPercents.append(percent) | ||
| } | ||
|
|
||
|
|
@@ -1126,10 +1140,11 @@ | |
| details.cursorApiUsage, | ||
| details.tokenUsagePercent, | ||
| details.mcpUsagePercent, | ||
| details.weeklyUsagePercent, | ||
| details.openCodeGoMonthlyUsage | ||
| ] | ||
| for percent in extraPercents { | ||
| if let normalized = normalizedUsagePercent(percent) { | ||
| if let normalized = Self.normalizedUsagePercent(percent) { | ||
| usedPercents.append(normalized) | ||
| } | ||
| } | ||
|
|
@@ -1146,7 +1161,7 @@ | |
|
|
||
| if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts { | ||
| for account in geminiAccounts { | ||
| if let percent = normalizedUsagePercent(100.0 - account.remainingPercentage) { | ||
| if let percent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) { | ||
| usedPercents.append(percent) | ||
| } | ||
| } | ||
|
|
@@ -1163,7 +1178,7 @@ | |
| kind: .cost | ||
| ) | ||
| case .quotaBased: | ||
| let cappedPercents = usedPercentsForChangeDetection(identifier: identifier, result: result).map { min($0, 100.0) } | ||
| let cappedPercents = Self.usedPercentsForChangeDetection(identifier: identifier, result: result).map { min($0, 100.0) } | ||
| // Use aggregate quota usage for change detection so non-max windows/accounts can still trigger updates. | ||
| let aggregatePercent = cappedPercents.isEmpty | ||
| ? min(max(result.usage.usagePercentage, 0.0), 100.0) | ||
|
|
@@ -2136,10 +2151,10 @@ | |
| ].compactMap { $0 } | ||
| usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents | ||
| } else if identifier == .zaiCodingPlan { | ||
| let percents = [account.details?.tokenUsagePercent, account.details?.mcpUsagePercent].compactMap { $0 } | ||
| let percents = Self.zaiCodingPlanTopLevelPercents(details: account.details) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The account-row and single-result branches still duplicate the provider-specific percentage selection. Extract one helper accepting |
||
| usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents | ||
| } else if identifier == .chutes { | ||
| let percents = [dailyPercentFromDetails(account.details), chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } | ||
| let percents = [Self.dailyPercentFromDetails(account.details), Self.chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } | ||
| usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents | ||
| } else if identifier == .nanoGpt { | ||
| let percents = [ | ||
|
|
@@ -2222,10 +2237,10 @@ | |
| ].compactMap { $0 } | ||
| usedPercents = percents.isEmpty ? [singlePercent] : percents | ||
| } else if identifier == .zaiCodingPlan { | ||
| let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 } | ||
| let percents = Self.zaiCodingPlanTopLevelPercents(details: result.details) | ||
| usedPercents = percents.isEmpty ? [singlePercent] : percents | ||
| } else if identifier == .chutes { | ||
| let percents = [dailyPercentFromDetails(result.details), chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } | ||
| let percents = [Self.dailyPercentFromDetails(result.details), Self.chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } | ||
| usedPercents = percents.isEmpty ? [singlePercent] : percents | ||
| } else if identifier == .nanoGpt { | ||
| let percents = [ | ||
|
|
@@ -2304,7 +2319,7 @@ | |
| for account in geminiAccounts { | ||
| hasQuota = true | ||
| let accountNumber = account.accountIndex + 1 | ||
| let usedPercent = normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0 | ||
| let usedPercent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0 | ||
| // Gemini account rows should represent Gemini quota only. | ||
| // Antigravity has its own provider row and should not be duplicated here. | ||
| let usedPercents: [Double] = [usedPercent] | ||
|
|
@@ -3843,7 +3858,7 @@ | |
| // 3. OpenRouter - only has current cost, no daily history | ||
| // We'll include today's cost if available | ||
| if let routerResult = providerResults[.openRouter], | ||
| case .payAsYouGo(_, let cost, _) = routerResult.usage, | ||
| let dailyCost = routerResult.details?.dailyUsage { | ||
| let today = Calendar.current.startOfDay(for: Date()) | ||
| if aggregatedDailyCosts[today] == nil { | ||
|
|
@@ -4248,22 +4263,33 @@ | |
| ) | ||
| ), | ||
| .zaiCodingPlan: ProviderResult( | ||
| usage: .quotaBased(remaining: 1, entitlement: 100, overagePermitted: false), | ||
| usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false), | ||
| details: DetailedUsage( | ||
| tokenUsagePercent: 99.0, | ||
| tokenUsagePercent: 12.0, | ||
| tokenUsageReset: oneDayFromNow, | ||
| tokenUsageUsed: 990_000, | ||
| tokenUsageTotal: 1_000_000, | ||
| mcpUsagePercent: 45.0, | ||
| mcpUsagePercent: 2.0, | ||
| mcpUsageReset: oneDayFromNow, | ||
| mcpUsageUsed: 45, | ||
| mcpUsageTotal: 100, | ||
| modelUsageTokens: 500_000, | ||
| modelUsageCalls: 128, | ||
| toolNetworkSearchCount: 42, | ||
| toolWebReadCount: 15, | ||
| toolZreadCount: 8 | ||
| ) | ||
| weeklyUsagePercent: 1.0, | ||
| weeklyUsageReset: sevenDaysFromNow | ||
| ), | ||
| accounts: [ | ||
| ProviderAccountResult( | ||
| accountIndex: 0, | ||
| accountId: "zai-session", | ||
| usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false), | ||
| details: DetailedUsage( | ||
| tokenUsagePercent: 12.0, | ||
| mcpUsagePercent: 2.0, | ||
| weeklyUsagePercent: 1.0 | ||
| ) | ||
| ), | ||
| ProviderAccountResult( | ||
| accountIndex: 1, | ||
| accountId: "zai-weekly", | ||
| usage: .quotaBased(remaining: 99, entitlement: 100, overagePermitted: false), | ||
| details: DetailedUsage(weeklyUsagePercent: 1.0) | ||
| ) | ||
| ] | ||
| ), | ||
| .geminiCLI: ProviderResult( | ||
| usage: .quotaBased(remaining: 85, entitlement: 100, overagePermitted: false), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -732,6 +732,22 @@ extension StatusBarController { | |
| submenu.addItem(item) | ||
| } | ||
|
|
||
| // === Weekly Usage (CREDIT_LIMIT unit=6, lite tier) === | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Z.AI token, MCP, and weekly branches repeat the same usage-window and limit-row construction used by sibling provider branches. Move the optional-window rendering into a small helper driven by window definitions, keeping only provider-specific labels and fields in data, so separators and reset handling do not drift across synchronized edits. |
||
| if let weeklyUsage = details.weeklyUsagePercent { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new Z.AI weekly branch repeats the optional-window/separator/row sequence already hand-written for several providers. Build a small window-descriptor list and one shared add-window routine for separator handling, leaving only provider-specific labels and fields in data; that keeps future window rows from diverging. |
||
| let items = createUsageWindowRow( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| label: "Weekly (7d)", | ||
| usagePercent: weeklyUsage, | ||
| resetDate: details.weeklyUsageReset, | ||
| windowHours: 24 * 7, | ||
| isMonthly: false | ||
| ) | ||
| items.forEach { submenu.addItem($0) } | ||
| } | ||
| if let weeklyUsed = details.weeklyUsageUsed, let weeklyTotal = details.weeklyUsageTotal { | ||
| let item = createLimitRow(label: "Weekly", used: Double(weeklyUsed), total: Double(weeklyTotal)) | ||
| submenu.addItem(item) | ||
| } | ||
|
|
||
| // === Last 24h stats (provider-specific, keep as-is) === | ||
| let numberFormatter = NumberFormatter() | ||
| numberFormatter.numberStyle = .decimal | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[3라운드째 미합의]
These controller algorithms and candidate types are made module-visible solely for direct tests. Keep them private and test the existing behavior path, or expose one narrowly scoped seam instead of widening several production APIs.