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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 7 additions & 8 deletions Sources/CodexBar/UsageStore+WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBarCore/Providers/Kimi/KimiModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct KimiCodeAPIUsageResponse: Codable {

struct KimiSubscriptionStatResponse: Codable {
let subscriptionBalance: KimiSubscriptionBalance?
let ratelimitCode7d: KimiSubscriptionRateLimit?
}

struct KimiSubscriptionBalance: Codable, Sendable {
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -133,6 +133,7 @@ public struct KimiUsageFetcher: Sendable {
weekly: codingUsage.detail,
rateLimit: codingUsage.limits?.first?.detail,
subscriptionBalance: subscriptionStat?.subscriptionBalance,
subscriptionCodeWeeklyLimit: subscriptionStat?.ratelimitCode7d,
updatedAt: now)
}

Expand Down
19 changes: 18 additions & 1 deletion Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodexBarWidget/CodexBarWidgetViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
9 changes: 5 additions & 4 deletions Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
}

Expand Down
45 changes: 44 additions & 1 deletion Tests/CodexBarTests/KimiProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
}
"""
Expand All @@ -524,17 +532,23 @@ struct KimiUsageResponseParsingTests {
if url.path.hasSuffix("/GetUsages") {
return (Data(usageJSON.utf8), response)
}
#expect(url.path.hasSuffix("/GetSubscriptionStats"))
return (Data(subscriptionJSON.utf8), response)
}

let snapshot = try await KimiUsageFetcher._fetchUsageForTesting(
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
Expand Down Expand Up @@ -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()
Expand Down
20 changes: 14 additions & 6 deletions Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down