Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE)
[![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app)

<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 66 providers." width="100%" /></a>
<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 67 providers." width="100%" /></a>

Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons.

Expand Down Expand Up @@ -117,6 +117,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
- [Mistral](docs/mistral.md) — Browser cookies for API spend, credit balance, and monthly-plan usage.
- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown).
- [Charm Hyper](docs/hyper.md) — Signed-in session or API key for remaining Hypercredit balance.
- [DeepInfra](docs/deepinfra.md) — API key for prepaid balance, current-month spend, and spending-limit tracking.
- [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking.
- [Venice](docs/venice.md) — API key for DIEM or USD balance tracking.
Expand Down
121 changes: 72 additions & 49 deletions Sources/CodexBar/MenuCardView+Costs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ extension UsageMenuCardView.Model {
preferredCurrencyCode: String = "auto") -> String?
{
guard metadata.supportsCredits else { return nil }
if metadata.id == .codex, credits == nil, error == nil { return nil }
if metadata.id == .codex, credits == nil, error == nil {
return nil
}
if metadata.id == .amp,
let ampUsage = snapshot?.ampUsage,
let ampCredits = self.ampCreditsLine(ampUsage, preferredCurrencyCode: preferredCurrencyCode)
Expand Down Expand Up @@ -338,13 +340,19 @@ extension UsageMenuCardView.Model {
return (entry, dayKey)
}
.max { lhs, rhs in
if lhs.dayKey != rhs.dayKey { return lhs.dayKey < rhs.dayKey }
if lhs.dayKey != rhs.dayKey {
return lhs.dayKey < rhs.dayKey
}
let lCost = lhs.entry.costUSD ?? -1
let rCost = rhs.entry.costUSD ?? -1
if lCost != rCost { return lCost < rCost }
if lCost != rCost {
return lCost < rCost
}
let lTokens = lhs.entry.totalTokens ?? -1
let rTokens = rhs.entry.totalTokens ?? -1
if lTokens != rTokens { return lTokens < rTokens }
if lTokens != rTokens {
return lTokens < rTokens
}
return lhs.entry.date < rhs.entry.date
}?.entry
}
Expand Down Expand Up @@ -396,8 +404,12 @@ extension UsageMenuCardView.Model {
private static func daysInBedrockBillingMonth(_ month: Int, year: Int) -> Int {
switch month {
case 2:
if year.isMultiple(of: 400) { return 29 }
if year.isMultiple(of: 100) { return 28 }
if year.isMultiple(of: 400) {
return 29
}
if year.isMultiple(of: 100) {
return 28
}
return year.isMultiple(of: 4) ? 29 : 28
case 4, 6, 9, 11:
return 30
Expand All @@ -406,6 +418,54 @@ extension UsageMenuCardView.Model {
}
}

/// Providers whose cost snapshot is a plain prepaid balance with no limit or period to chart.
private static func balanceOnlyCostSection(
provider: UsageProvider,
cost: ProviderCostSnapshot,
formatCost: (Double) -> String) -> ProviderCostSection?
{
func balanceSection(title: String, balance: String) -> ProviderCostSection {
ProviderCostSection(
title: title,
percentUsed: nil,
spendLine: "\(L("Balance")): \(balance)",
percentLine: nil)
}

if provider == .factory || provider == .devin, cost.period == "Extra usage balance" {
return balanceSection(title: L("Extra usage"), balance: formatCost(cost.used))
}

if provider == .opencodego, cost.period == "Zen balance" {
return balanceSection(title: L("Zen balance"), balance: formatCost(cost.used))
}

if provider == .minimax, cost.period == "MiniMax points balance" {
return balanceSection(title: L("Credits"), balance: String(format: "%.0f", cost.used))
}

if provider == .hyper,
cost.period == "Hypercredits balance",
let value = cost.balance
{
let balance = value.rounded() == value
? String(format: "%.0f", value)
: String(format: "%.2f", value)
return balanceSection(title: "Hypercredits", balance: "\(balance) HC")
}

if provider == .xai, cost.period == "Prepaid credits" {
let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
return balanceSection(title: L("Credits"), balance: balance)
}

if provider == .zenmux || provider == .neuralwatt {
return balanceSection(title: L("metric_mistral_payg"), balance: formatCost(cost.used))
}

return nil
}

static func providerCostSection(
provider: UsageProvider,
cost: ProviderCostSnapshot?,
Expand All @@ -426,49 +486,12 @@ extension UsageMenuCardView.Model {
providerCurrency: providerCurrency ?? cost.currencyCode)
}

if provider == .factory || provider == .devin, cost.period == "Extra usage balance" {
let balance = formatCost(cost.used)
return ProviderCostSection(
title: L("Extra usage"),
percentUsed: nil,
spendLine: "\(L("Balance")): \(balance)",
percentLine: nil)
}

if provider == .opencodego, cost.period == "Zen balance" {
let balance = formatCost(cost.used)
return ProviderCostSection(
title: L("Zen balance"),
percentUsed: nil,
spendLine: "\(L("Balance")): \(balance)",
percentLine: nil)
}

if provider == .minimax, cost.period == "MiniMax points balance" {
let balance = String(format: "%.0f", cost.used)
return ProviderCostSection(
title: L("Credits"),
percentUsed: nil,
spendLine: "\(L("Balance")): \(balance)",
percentLine: nil)
}

if provider == .xai, cost.period == "Prepaid credits" {
let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
return ProviderCostSection(
title: L("Credits"),
percentUsed: nil,
spendLine: "\(L("Balance")): \(balance)",
percentLine: nil)
}

if provider == .zenmux || provider == .neuralwatt {
let balance = formatCost(cost.used)
return ProviderCostSection(
title: L("metric_mistral_payg"),
percentUsed: nil,
spendLine: "\(L("Balance")): \(balance)",
percentLine: nil)
if let section = Self.balanceOnlyCostSection(
provider: provider,
cost: cost,
formatCost: { formatCost($0) })
{
return section
}

if provider == .claude {
Expand Down
109 changes: 109 additions & 0 deletions Sources/CodexBar/Providers/Hyper/HyperProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import AppKit
import CodexBarCore
import Foundation
import SwiftUI

struct HyperProviderImplementation: ProviderImplementation {
let id: UsageProvider = .hyper

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { context in
context.store.sourceLabel(for: context.provider)
}
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.hyperAPIKey
_ = settings.hyperCookieSource
_ = settings.hyperCookieHeader
_ = settings.tokenAccountsData(for: .hyper)
}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
.hyper(context.settings.hyperSettingsSnapshot())
}

@MainActor
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
let binding = Binding(
get: { context.settings.hyperCookieSource.rawValue },
set: { raw in
context.settings.hyperCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
})
let options = ProviderCookieSourceUI.options(
allowsOff: true,
keychainDisabled: context.settings.debugDisableKeychainAccess)
let subtitle: () -> String? = {
ProviderCookieSourceUI.subtitle(
source: context.settings.hyperCookieSource,
keychainDisabled: context.settings.debugDisableKeychainAccess,
auto: "Prefer a signed-in Hyper session from Chrome, then fall back to an API key.",
manual: "Paste a Cookie header from hyper.charm.land.",
off: "Use only the configured API key.")
}

return [
ProviderSettingsPickerDescriptor(
id: "hyper-cookie-source",
title: "Session source",
subtitle: "Prefer a signed-in Hyper session, then fall back to an API key.",
dynamicSubtitle: subtitle,
binding: binding,
options: options,
isVisible: nil,
onChange: nil,
trailingText: {
ProviderCookieRefreshAction.trailingText(
provider: .hyper,
cookieSource: context.settings.hyperCookieSource,
context: context)
},
trailingActions: [
ProviderCookieRefreshAction.descriptor(
provider: .hyper,
cookieSource: { context.settings.hyperCookieSource },
context: context),
]),
]
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "hyper-cookie",
title: "Hyper cookie",
subtitle: "Paste a Cookie header copied from a signed-in hyper.charm.land request.",
kind: .secure,
placeholder: "Cookie: ...",
binding: context.stringBinding(\.hyperCookieHeader),
actions: [
ProviderSettingsActionDescriptor(
id: "hyper-open-dashboard",
title: "Open Charm Hyper",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://hyper.charm.land") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: { context.settings.hyperCookieSource == .manual },
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "hyper-api-key",
title: "API key",
subtitle: "Fallback when no signed-in Hyper session is available. Stored in the CodexBar config file.",
kind: .secure,
placeholder: "Paste API key…",
binding: context.stringBinding(\.hyperAPIKey),
actions: [],
isVisible: nil,
onActivate: nil),
]
}
}
40 changes: 40 additions & 0 deletions Sources/CodexBar/Providers/Hyper/HyperSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var hyperCookieSource: ProviderCookieSource {
get { self.resolvedCookieSource(provider: .hyper, fallback: .auto) }
set {
self.updateProviderConfig(provider: .hyper) { entry in
entry.cookieSource = newValue
}
self.logProviderModeChange(provider: .hyper, field: "cookieSource", value: newValue.rawValue)
}
}

var hyperCookieHeader: String {
get { self.configSnapshot.providerConfig(for: .hyper)?.sanitizedCookieHeader ?? "" }
set {
self.updateProviderConfig(provider: .hyper) { entry in
entry.cookieHeader = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .hyper, field: "cookieHeader", value: newValue)
}
}

var hyperAPIKey: String {
get { self.configSnapshot.providerConfig(for: .hyper)?.sanitizedAPIKey ?? "" }
set {
self.updateProviderConfig(provider: .hyper) { entry in
entry.apiKey = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .hyper, field: "apiKey", value: newValue)
}
}

func hyperSettingsSnapshot() -> ProviderSettingsSnapshot.CookieProviderSettings {
ProviderSettingsSnapshot.CookieProviderSettings(
cookieSource: self.hyperCookieSource,
manualCookieHeader: self.hyperCookieHeader)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ enum ProviderImplementationRegistry {
case .mistral: MistralProviderImplementation()
case .deepseek: DeepSeekProviderImplementation()
case .deepinfra: DeepInfraProviderImplementation()
case .hyper: HyperProviderImplementation()
case .codebuff: CodebuffProviderImplementation()
case .crof: CrofProviderImplementation()
case .venice: VeniceProviderImplementation()
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-hyper.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion Sources/CodexBar/SettingsStore+MenuPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ extension SettingsStore {

static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool {
switch provider {
case .deepseek, .deepinfra, .mistral, .moonshot, .poe:
case .deepseek, .deepinfra, .hyper, .mistral, .moonshot, .poe:
true
default:
false
Expand Down
22 changes: 22 additions & 0 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,11 @@ extension StatusItemController {
{
return balance
}
if provider == .hyper,
let balance = Self.hyperBalanceDisplayText(snapshot: snapshot)
{
return balance
}
if provider == .mimo,
let balance = Self.miMoBalanceDisplayText(
snapshot: snapshot,
Expand Down Expand Up @@ -1015,6 +1020,23 @@ extension StatusItemController {
return prefix + String(value)
}

nonisolated static func hyperBalanceDisplayText(snapshot: UsageSnapshot?) -> String? {
guard snapshot?.primary == nil,
snapshot?.secondary == nil,
let cost = snapshot?.providerCost,
cost.period == "Hypercredits balance",
let value = cost.balance,
value.isFinite,
value >= 0
else {
return nil
}
let balance = value.rounded() == value
? String(format: "%.0f", value)
: String(format: "%.2f", value)
return "\(balance) HC"
}

nonisolated static func miMoBalanceDisplayText(
snapshot: UsageSnapshot?,
preference: MenuBarMetricPreference) -> String?
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/UsageStore+Accessors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ extension UsageStore {
return DeepSeekUsageError.missingCredentials.errorDescription
case .deepinfra:
return DeepInfraUsageError.missingCredentials.errorDescription
case .hyper:
return HyperUsageError.missingCredentials.errorDescription
case .perplexity:
return PerplexityAPIError.missingToken.errorDescription
case .minimax:
Expand Down
Loading