Skip to content

Commit c7f3866

Browse files
authored
feat: show balance metrics for API-balance providers
Shows balance-style menu bar text for OpenRouter, Mistral, and Kimi K2 where quota percentage is not the useful default. Validated locally with targeted balance/provider tests and pnpm check.
1 parent c727684 commit c7f3866

10 files changed

Lines changed: 239 additions & 38 deletions

Sources/CodexBar/PreferencesProvidersPane.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ struct ProvidersPane: View {
459459
id: MenuBarMetricPreference.primary.rawValue,
460460
title: "Primary (API key limit)"),
461461
]
462-
} else if provider == .deepseek {
462+
} else if SettingsStore.isBalanceOnlyProvider(provider) {
463463
options = [
464464
ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"),
465465
]
@@ -507,9 +507,7 @@ struct ProvidersPane: View {
507507
return ProviderSettingsPickerDescriptor(
508508
id: "menuBarMetric",
509509
title: "Menu bar metric",
510-
subtitle: provider == .deepseek
511-
? "Shows the DeepSeek balance in the menu bar."
512-
: "Choose which window drives the menu bar percent.",
510+
subtitle: Self.menuBarMetricPickerSubtitle(for: provider),
513511
binding: Binding(
514512
get: {
515513
self.settings
@@ -525,6 +523,19 @@ struct ProvidersPane: View {
525523
onChange: nil)
526524
}
527525

526+
private static func menuBarMetricPickerSubtitle(for provider: UsageProvider) -> String {
527+
switch provider {
528+
case .deepseek:
529+
"Shows the DeepSeek balance in the menu bar."
530+
case .mistral:
531+
"Shows current-month Mistral API spend in the menu bar."
532+
case .kimik2:
533+
"Shows Kimi K2 API-key credits in the menu bar."
534+
default:
535+
"Choose which window drives the menu bar percent."
536+
}
537+
}
538+
528539
func menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model {
529540
let metadata = self.store.metadata(for: provider)
530541
let snapshot = self.store.snapshot(for: provider)

Sources/CodexBar/SettingsStore+MenuPreferences.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import Foundation
33

44
extension SettingsStore {
55
func menuBarMetricPreference(for provider: UsageProvider) -> MenuBarMetricPreference {
6+
if Self.isBalanceOnlyProvider(provider) {
7+
return .automatic
8+
}
69
if provider == .openrouter {
710
let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? ""
811
let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic
@@ -28,6 +31,10 @@ extension SettingsStore {
2831
}
2932

3033
func setMenuBarMetricPreference(_ preference: MenuBarMetricPreference, for provider: UsageProvider) {
34+
if Self.isBalanceOnlyProvider(provider) {
35+
self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue
36+
return
37+
}
3138
if provider == .openrouter {
3239
switch preference {
3340
case .automatic, .primary:
@@ -96,4 +103,13 @@ extension SettingsStore {
96103
var resetTimeDisplayStyle: ResetTimeDisplayStyle {
97104
self.resetTimesShowAbsolute ? .absolute : .countdown
98105
}
106+
107+
static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool {
108+
switch provider {
109+
case .deepseek, .mistral, .kimik2:
110+
true
111+
default:
112+
false
113+
}
114+
}
99115
}

Sources/CodexBar/StatusItemController+Animation.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,16 @@ extension StatusItemController {
534534
{
535535
return balance
536536
}
537+
if provider == .mistral,
538+
let spend = Self.mistralSpendDisplayText(snapshot: snapshot)
539+
{
540+
return spend
541+
}
542+
if provider == .kimik2,
543+
let credits = Self.kimiK2CreditsDisplayText(snapshot: snapshot)
544+
{
545+
return credits
546+
}
537547

538548
let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot)
539549
let mode = self.settings.menuBarDisplayMode
@@ -589,6 +599,39 @@ extension StatusItemController {
589599
return balance.map(String.init)
590600
}
591601

602+
nonisolated static func mistralSpendDisplayText(snapshot: UsageSnapshot?) -> String? {
603+
self.displayValue(
604+
from: snapshot?.identity?.loginMethod,
605+
prefix: "API spend:",
606+
removingSuffix: " this month")
607+
}
608+
609+
nonisolated static func kimiK2CreditsDisplayText(snapshot: UsageSnapshot?) -> String? {
610+
self.displayValue(
611+
from: snapshot?.identity?.loginMethod,
612+
prefix: "Credits:",
613+
removingSuffix: " left")
614+
}
615+
616+
private nonisolated static func displayValue(
617+
from text: String?,
618+
prefix: String,
619+
removingSuffix suffix: String)
620+
-> String?
621+
{
622+
guard let rawValue = text?.trimmingCharacters(in: .whitespacesAndNewlines),
623+
rawValue.hasPrefix(prefix)
624+
else {
625+
return nil
626+
}
627+
let valueStart = rawValue.index(rawValue.startIndex, offsetBy: prefix.count)
628+
var value = rawValue[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
629+
if value.hasSuffix(suffix) {
630+
value = String(value.dropLast(suffix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
631+
}
632+
return value.isEmpty ? nil : value
633+
}
634+
592635
private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? {
593636
self.menuBarMetricWindow(for: provider, snapshot: snapshot)
594637
}

Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,13 @@ public struct KimiK2UsageSummary: Sendable {
2929
}
3030

3131
public func toUsageSnapshot() -> UsageSnapshot {
32-
let total = max(0, self.consumed + self.remaining)
33-
let usedPercent: Double = if total > 0 {
34-
min(100, max(0, (self.consumed / total) * 100))
35-
} else {
36-
0
37-
}
38-
let usedText = String(format: "%.0f", self.consumed)
39-
let totalText = String(format: "%.0f", total)
40-
let rateWindow = RateWindow(
41-
usedPercent: usedPercent,
42-
windowMinutes: nil,
43-
resetsAt: nil,
44-
resetDescription: total > 0 ? "Credits: \(usedText)/\(totalText)" : nil)
4532
let identity = ProviderIdentitySnapshot(
4633
providerID: .kimik2,
4734
accountEmail: nil,
4835
accountOrganization: nil,
49-
loginMethod: nil)
36+
loginMethod: "Credits: \(UsageFormatter.creditsString(from: self.remaining))")
5037
return UsageSnapshot(
51-
primary: rateWindow,
38+
primary: nil,
5239
secondary: nil,
5340
tertiary: nil,
5441
providerCost: nil,

Sources/CodexBarCore/Providers/Mistral/MistralModels.swift

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,23 +102,48 @@ public struct MistralUsageSnapshot: Sendable {
102102
public let endDate: Date?
103103
public let updatedAt: Date
104104

105+
public init(
106+
totalCost: Double,
107+
currency: String,
108+
currencySymbol: String,
109+
totalInputTokens: Int,
110+
totalOutputTokens: Int,
111+
totalCachedTokens: Int,
112+
modelCount: Int,
113+
startDate: Date?,
114+
endDate: Date?,
115+
updatedAt: Date)
116+
{
117+
self.totalCost = totalCost
118+
self.currency = currency
119+
self.currencySymbol = currencySymbol
120+
self.totalInputTokens = totalInputTokens
121+
self.totalOutputTokens = totalOutputTokens
122+
self.totalCachedTokens = totalCachedTokens
123+
self.modelCount = modelCount
124+
self.startDate = startDate
125+
self.endDate = endDate
126+
self.updatedAt = updatedAt
127+
}
128+
105129
public func toUsageSnapshot() -> UsageSnapshot {
106-
let resetDate = self.endDate.map { Calendar.current.date(byAdding: .second, value: 1, to: $0) ?? $0 }
107-
let costDescription = if self.totalCost > 0 {
130+
// Negative totalCost means a refund/credit adjustment; clamp to zero rather than
131+
// showing a confusing negative amount in the menu bar.
132+
let spendText = if self.totalCost > 0 {
108133
"\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month"
109134
} else {
110-
"No usage this month"
135+
"\(self.currencySymbol)0.0000 this month"
111136
}
112-
let primary = RateWindow(
113-
usedPercent: 0,
114-
windowMinutes: nil,
115-
resetsAt: resetDate,
116-
resetDescription: costDescription)
137+
let identity = ProviderIdentitySnapshot(
138+
providerID: .mistral,
139+
accountEmail: nil,
140+
accountOrganization: nil,
141+
loginMethod: "API spend: \(spendText)")
117142
return UsageSnapshot(
118-
primary: primary,
143+
primary: nil,
119144
secondary: nil,
120145
providerCost: nil,
121146
updatedAt: self.updatedAt,
122-
identity: nil)
147+
identity: identity)
123148
}
124149
}

Tests/CodexBarTests/KimiK2UsageFetcherTests.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,17 @@ struct KimiK2UsageFetcherTests {
8585
return message == "Root JSON is not an object."
8686
}
8787
}
88+
89+
@Test
90+
func `converts api key credits into text only snapshot`() {
91+
let usage = KimiK2UsageSummary(
92+
consumed: 10,
93+
remaining: 25,
94+
averageTokens: nil,
95+
updatedAt: Date()).toUsageSnapshot()
96+
97+
#expect(usage.primary == nil)
98+
#expect(usage.identity?.providerID == .kimik2)
99+
#expect(usage.identity?.loginMethod == "Credits: 25 left")
100+
}
88101
}

Tests/CodexBarTests/MistralUsageParserTests.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ struct MistralUsageParserTests {
8282

8383
struct MistralUsageSnapshotConversionTests {
8484
@Test
85-
func `converts cost into primary resetDescription so it surfaces as detail text`() {
85+
func `converts cost into text only current month api spend`() {
8686
let snapshot = MistralUsageSnapshot(
8787
totalCost: 1.2345,
8888
currency: "EUR",
@@ -96,17 +96,14 @@ struct MistralUsageSnapshotConversionTests {
9696
updatedAt: Date())
9797

9898
let usage = snapshot.toUsageSnapshot()
99-
#expect(usage.primary != nil)
100-
#expect(usage.primary?.usedPercent == 0)
101-
#expect(usage.primary?.resetDescription?.contains("€1.2345") == true)
102-
// providerCost is intentionally nil: the menu card's providerCostSection requires
103-
// limit > 0 to render a bar, and Mistral is pay-as-you-go with no quota. The cost
104-
// is surfaced via primary.resetDescription (rendered as detail text in the card).
99+
#expect(usage.primary == nil)
100+
#expect(usage.identity?.providerID == .mistral)
101+
#expect(usage.identity?.loginMethod == "API spend: €1.2345 this month")
105102
#expect(usage.providerCost == nil)
106103
}
107104

108105
@Test
109-
func `converts zero cost with no-usage description`() {
106+
func `converts zero cost into zero spend text`() {
110107
let snapshot = MistralUsageSnapshot(
111108
totalCost: 0,
112109
currency: "USD",
@@ -120,7 +117,8 @@ struct MistralUsageSnapshotConversionTests {
120117
updatedAt: Date())
121118

122119
let usage = snapshot.toUsageSnapshot()
123-
#expect(usage.primary?.resetDescription == "No usage this month")
120+
#expect(usage.primary == nil)
121+
#expect(usage.identity?.loginMethod == "API spend: $0.0000 this month")
124122
}
125123
}
126124

Tests/CodexBarTests/ProvidersPaneCoverageTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,32 @@ struct ProvidersPaneCoverageTests {
4444
#expect(picker?.subtitle == "Shows the DeepSeek balance in the menu bar.")
4545
}
4646

47+
@Test
48+
func `mistral menu bar metric picker shows spend only copy`() {
49+
let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-mistral-picker")
50+
let store = Self.makeUsageStore(settings: settings)
51+
let pane = ProvidersPane(settings: settings, store: store)
52+
53+
let picker = pane._test_menuBarMetricPicker(for: .mistral)
54+
#expect(picker?.options.map(\.id) == [
55+
MenuBarMetricPreference.automatic.rawValue,
56+
])
57+
#expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.")
58+
}
59+
60+
@Test
61+
func `kimi k2 menu bar metric picker shows credits only copy`() {
62+
let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimik2-picker")
63+
let store = Self.makeUsageStore(settings: settings)
64+
let pane = ProvidersPane(settings: settings, store: store)
65+
66+
let picker = pane._test_menuBarMetricPicker(for: .kimik2)
67+
#expect(picker?.options.map(\.id) == [
68+
MenuBarMetricPreference.automatic.rawValue,
69+
])
70+
#expect(picker?.subtitle == "Shows Kimi K2 API-key credits in the menu bar.")
71+
}
72+
4773
@Test
4874
func `cursor menu bar metric picker omits tertiary api lane when snapshot has no api metric`() {
4975
let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-tertiary-picker")

Tests/CodexBarTests/SettingsStoreAdditionalTests.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ struct SettingsStoreAdditionalTests {
7070
#expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic)
7171
}
7272

73+
@Test
74+
func `menu bar metric preference restricts text only balance providers to automatic`() {
75+
let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-text-only-metric")
76+
77+
for provider in [UsageProvider.deepseek, .mistral, .kimik2] {
78+
settings.setMenuBarMetricPreference(.primary, for: provider)
79+
#expect(settings.menuBarMetricPreference(for: provider) == .automatic)
80+
81+
settings.setMenuBarMetricPreference(.secondary, for: provider)
82+
#expect(settings.menuBarMetricPreference(for: provider) == .automatic)
83+
}
84+
}
85+
7386
@Test
7487
func `minimax auth mode uses stored values`() {
7588
let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-minimax")

0 commit comments

Comments
 (0)