From 6ef79606f5afadf4963a8534f5c2ea8927193e26 Mon Sep 17 00:00:00 2001 From: skyzer Date: Mon, 6 Jul 2026 15:50:32 +0300 Subject: [PATCH] Fix Kimi subscription quotas --- CHANGELOG.md | 2 + .../CodexBar/UsageStore+WidgetSnapshot.swift | 15 +++---- .../Providers/Kimi/KimiModels.swift | 7 +++ .../Providers/Kimi/KimiUsageFetcher.swift | 3 +- .../Providers/Kimi/KimiUsageSnapshot.swift | 19 +++++++- .../CodexBarWidget/CodexBarWidgetViews.swift | 4 +- .../CodexBarWidgetProviderTests.swift | 9 ++-- Tests/CodexBarTests/KimiProviderTests.swift | 45 ++++++++++++++++++- .../UsageStoreWidgetSnapshotTests.swift | 20 ++++++--- 9 files changed, 101 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e3ed0d86..e7f41ff82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,14 @@ ### Added - Widgets: make Kimi available with Weekly, Rate Limit, and Monthly quota rows. Thanks @joeVenner! +- Kimi: show the subscription 7-day Code quota from the membership stats endpoint. Thanks @Skyzer! ### Fixed - Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings. - Codex quotas: show the session quota as unavailable while an exhausted weekly limit is still binding, including menu-bar icons and widgets. Thanks @Yuxin-Qiao! - Codex cost history: reuse cached aggregate pricing and one pricing catalog across daily and project reports, carry fresh cache state across launches, and treat unpriced models as migrated, avoiding repeated row scans, filesystem work, and duplicate background scans on large local histories. - Kimi K2: reject non-finite credit and token values before they reach menus, CLI output, or widgets. Thanks @joeVenner! +- Kimi: call the current `GetSubscriptionStats` membership endpoint so the Monthly subscription quota is populated again. Thanks @Skyzer! - Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! ## 0.40.0 — 2026-07-05 diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index ee385c2a73..3a8aeaff08 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -181,14 +181,13 @@ extension UsageStore { title: metadata?.opusLabel ?? "Opus", percentLeft: snapshot.tertiary?.remainingPercent)) } - if provider == .kimi, - let monthly = snapshot.extraRateWindows?.first(where: { $0.id == "kimi-monthly" }), - monthly.usageKnown - { - rows.append(WidgetSnapshot.WidgetUsageRowSnapshot( - id: monthly.id, - title: monthly.title, - percentLeft: monthly.window.remainingPercent)) + if provider == .kimi { + rows.append(contentsOf: snapshot.extraRateWindows?.filter(\.usageKnown).map { window in + WidgetSnapshot.WidgetUsageRowSnapshot( + id: window.id, + title: window.title, + percentLeft: window.window.remainingPercent) + } ?? []) } return rows.filter { $0.percentLeft != nil } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift index e4cdcc3835..0c25b81646 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift @@ -11,6 +11,7 @@ struct KimiCodeAPIUsageResponse: Codable { struct KimiSubscriptionStatResponse: Codable { let subscriptionBalance: KimiSubscriptionBalance? + let ratelimitCode7d: KimiSubscriptionRateLimit? } struct KimiSubscriptionBalance: Codable, Sendable { @@ -20,6 +21,12 @@ struct KimiSubscriptionBalance: Codable, Sendable { let expireTime: String? } +struct KimiSubscriptionRateLimit: Codable, Sendable { + let ratio: Double? + let enabled: Bool? + let resetTime: String? +} + struct KimiUsage: Codable { let scope: String let detail: KimiUsageDetail diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index ecfcc2bc41..81cd224d6e 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -10,7 +10,7 @@ public struct KimiUsageFetcher: Sendable { private static let usageURL = URL(string: "https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages")! private static let subscriptionStatURL = - URL(string: "https://www.kimi.com/apiv2/kimi.gateway.membership.v2.MembershipService/GetSubscriptionStat")! + URL(string: "https://www.kimi.com/apiv2/kimi.gateway.membership.v2.MembershipService/GetSubscriptionStats")! public static func fetchCodeAPIUsage( apiKey: String, @@ -133,6 +133,7 @@ public struct KimiUsageFetcher: Sendable { weekly: codingUsage.detail, rateLimit: codingUsage.limits?.first?.detail, subscriptionBalance: subscriptionStat?.subscriptionBalance, + subscriptionCodeWeeklyLimit: subscriptionStat?.ratelimitCode7d, updatedAt: now) } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index 8c9195505d..a4a9a7d799 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -5,23 +5,27 @@ public struct KimiUsageSnapshot: Sendable { public let rateLimit: KimiUsageDetail? public let updatedAt: Date let subscriptionBalance: KimiSubscriptionBalance? + let subscriptionCodeWeeklyLimit: KimiSubscriptionRateLimit? public init(weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, updatedAt: Date) { self.weekly = weekly self.rateLimit = rateLimit self.updatedAt = updatedAt self.subscriptionBalance = nil + self.subscriptionCodeWeeklyLimit = nil } init( weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, subscriptionBalance: KimiSubscriptionBalance?, + subscriptionCodeWeeklyLimit: KimiSubscriptionRateLimit? = nil, updatedAt: Date) { self.weekly = weekly self.rateLimit = rateLimit self.subscriptionBalance = subscriptionBalance + self.subscriptionCodeWeeklyLimit = subscriptionCodeWeeklyLimit self.updatedAt = updatedAt } @@ -100,6 +104,19 @@ extension KimiUsageSnapshot { return NamedRateWindow(id: "kimi-monthly", title: "Monthly", window: window) } + let subscriptionCodeWeeklyWindow = self.subscriptionCodeWeeklyLimit.flatMap { limit -> NamedRateWindow? in + guard limit.enabled != false else { return nil } + guard let ratio = limit.ratio else { return nil } + let window = RateWindow( + usedPercent: Self.clampedPercent(ratio * 100), + windowMinutes: 7 * 24 * 60, + resetsAt: Self.parseDate(limit.resetTime), + resetDescription: nil) + return NamedRateWindow(id: "kimi-code-7d", title: "Code 7-day", window: window) + } + + let extraRateWindows = [monthlyWindow, subscriptionCodeWeeklyWindow].compactMap(\.self) + let identity = ProviderIdentitySnapshot( providerID: .kimi, accountEmail: nil, @@ -110,7 +127,7 @@ extension KimiUsageSnapshot { primary: weeklyWindow, secondary: rateLimitWindow, tertiary: nil, - extraRateWindows: monthlyWindow.map { [$0] }, + extraRateWindows: extraRateWindows.isEmpty ? nil : extraRateWindows, providerCost: nil, updatedAt: self.updatedAt, identity: identity) diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 471b7f4367..94b786c517 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -591,12 +591,12 @@ struct WidgetUsageRow: Identifiable, Equatable { } static func smallWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { - if entry.provider == .kimi { return 3 } + if entry.provider == .kimi { return 4 } return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2) } static func mediumWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { - if entry.provider == .kimi { return 3 } + if entry.provider == .kimi { return 4 } return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3) } diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index e21ab1ff47..c27e2d9018 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -376,7 +376,7 @@ struct CodexBarWidgetProviderTests { } @Test - func `small and medium Kimi widgets keep all three persisted lane rows`() { + func `small and medium Kimi widgets keep all persisted quota rows`() { let now = Date(timeIntervalSince1970: 1_700_000_000) let entry = WidgetSnapshot.ProviderEntry( provider: .kimi, @@ -388,6 +388,7 @@ struct CodexBarWidgetProviderTests { WidgetSnapshot.WidgetUsageRowSnapshot(id: "primary", title: "Weekly", percentLeft: 75), WidgetSnapshot.WidgetUsageRowSnapshot(id: "secondary", title: "Rate Limit", percentLeft: 50), WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-monthly", title: "Monthly", percentLeft: 25), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-code-7d", title: "Code 7-day", percentLeft: 90), ], creditsRemaining: nil, codeReviewRemainingPercent: nil, @@ -401,9 +402,9 @@ struct CodexBarWidgetProviderTests { for: entry, limit: WidgetUsageRow.mediumWidgetRowLimit(for: entry)) - #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == 3) - #expect(WidgetUsageRow.mediumWidgetRowLimit(for: entry) == 3) - #expect(smallRows.map(\.id) == ["primary", "secondary", "kimi-monthly"]) + #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == 4) + #expect(WidgetUsageRow.mediumWidgetRowLimit(for: entry) == 4) + #expect(smallRows.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"]) #expect(mediumRows == smallRows) } diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index 1e15354222..f06dbe3394 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -432,6 +432,9 @@ struct KimiUsageResponseParsingTests { #expect(response.subscriptionBalance?.type == "SUBSCRIPTION") #expect(response.subscriptionBalance?.amountUsedRatio == 1) #expect(response.subscriptionBalance?.expireTime == "2026-07-23T00:00:00Z") + #expect(response.ratelimitCode7d?.ratio == 0.0946) + #expect(response.ratelimitCode7d?.enabled == true) + #expect(response.ratelimitCode7d?.resetTime == "2026-07-09T06:56:36.876796734Z") } @Test @@ -511,6 +514,11 @@ struct KimiUsageResponseParsingTests { "type": "SUBSCRIPTION", "amountUsedRatio": 0.42, "expireTime": "2026-07-23T00:00:00Z" + }, + "ratelimitCode7d": { + "ratio": 0.17, + "enabled": true, + "resetTime": "2026-07-13T15:28:00Z" } } """ @@ -524,6 +532,7 @@ struct KimiUsageResponseParsingTests { if url.path.hasSuffix("/GetUsages") { return (Data(usageJSON.utf8), response) } + #expect(url.path.hasSuffix("/GetSubscriptionStats")) return (Data(subscriptionJSON.utf8), response) } @@ -531,10 +540,15 @@ struct KimiUsageResponseParsingTests { authToken: "test-token", transport: transport, subscriptionGrace: .seconds(1)) - let monthly = try #require(snapshot.toUsageSnapshot().extraRateWindows?.first) + let windows = try #require(snapshot.toUsageSnapshot().extraRateWindows) + let monthly = try #require(windows.first { $0.id == "kimi-monthly" }) + let weeklyCode = try #require(windows.first { $0.id == "kimi-code-7d" }) #expect(monthly.id == "kimi-monthly") #expect(monthly.window.usedPercent == 42) + #expect(weeklyCode.title == "Code 7-day") + #expect(weeklyCode.window.usedPercent == 17) + #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60) } @Test @@ -723,6 +737,35 @@ struct KimiUsageSnapshotConversionTests { #expect(abs(monthly.window.usedPercent - 77.16) < 0.0001) } + @Test + func `converts subscription code weekly limit to extra window`() throws { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let subscriptionCodeWeeklyLimit = KimiSubscriptionRateLimit( + ratio: 0.0946, + enabled: true, + resetTime: "2026-07-13T15:28:00Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: nil, + subscriptionCodeWeeklyLimit: subscriptionCodeWeeklyLimit, + updatedAt: now) + let usageSnapshot = snapshot.toUsageSnapshot() + + let weeklyCode = try #require(usageSnapshot.extraRateWindows?.first) + #expect(weeklyCode.id == "kimi-code-7d") + #expect(weeklyCode.title == "Code 7-day") + #expect(abs(weeklyCode.window.usedPercent - 9.46) < 0.0001) + #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60) + #expect(weeklyCode.window.resetsAt == Self.date("2026-07-13T15:28:00Z")) + } + @Test func `converts to usage snapshot without rate limit`() { let now = Date() diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index fbdf8124ea..51b5bd31f6 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -53,8 +53,8 @@ struct UsageStoreWidgetSnapshotTests { } @Test - func `widget snapshot includes Kimi monthly quota`() async throws { - let suite = "UsageStoreWidgetSnapshotTests-kimi-monthly" + func `widget snapshot includes Kimi subscription quota rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-kimi-subscription-rows" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -82,6 +82,14 @@ struct UsageStoreWidgetSnapshotTests { windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "kimi-code-7d", + title: "Code 7-day", + window: RateWindow( + usedPercent: 10, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), ], updatedAt: Date(), identity: ProviderIdentitySnapshot( @@ -95,14 +103,14 @@ struct UsageStoreWidgetSnapshotTests { store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } defer { store._test_widgetSnapshotSaveOverride = nil } - store.persistWidgetSnapshot(reason: "kimi-monthly-test") + store.persistWidgetSnapshot(reason: "kimi-subscription-rows-test") await store.widgetSnapshotPersistTask?.value let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .kimi }) // Widgets preserve persisted lane order; menu-only presentation may reorder these lanes. - #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "kimi-monthly"]) - #expect(entry.usageRows?.map(\.title) == ["Weekly", "Rate Limit", "Monthly"]) - #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25]) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"]) + #expect(entry.usageRows?.map(\.title) == ["Weekly", "Rate Limit", "Monthly", "Code 7-day"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25, 90]) } @Test