Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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. 62 providers." width="100%" /></a>
<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 63 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 @@ -135,6 +135,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics.
- [Poe](docs/poe.md) — API key for current point balance and recent points history.
- [Chutes](docs/chutes.md) — API key for subscription usage, rolling and monthly quota windows, and pay-as-you-go quotas.
- [Neuralwatt](docs/neuralwatt.md) — API key for subscription kWh usage and prepaid credit balance.
- [ZenMux](docs/zenmux.md) — Management API key for rolling five-hour and seven-day quota windows plus PAYG balance.
- Open to new providers: [provider authoring guide](docs/provider.md).

Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/MenuCardView+Costs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ extension UsageMenuCardView.Model {
percentLine: nil)
}

if provider == .zenmux {
if provider == .zenmux || provider == .neuralwatt {
let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
return ProviderCostSection(
title: L("metric_mistral_payg"),
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1280,7 +1280,7 @@ extension UsageMenuCardView.Model {
{
primaryDetailLeft = detail
}
if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .litellm].contains(input.provider),
if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .neuralwatt, .litellm].contains(input.provider),
let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
Expand Down Expand Up @@ -1309,7 +1309,7 @@ extension UsageMenuCardView.Model {
primaryResetText = nil
}
}
if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .litellm, .zenmux].contains(input.provider),
if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .neuralwatt, .litellm, .zenmux].contains(input.provider),
primary.resetsAt == nil
{
primaryResetText = nil
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ struct MenuDescriptor {
if let primary = snap.primary {
let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus ||
provider == .deepseek || provider == .azureopenai || provider == .mimo || provider == .qoder ||
provider == .sub2api
provider == .deepseek || provider == .neuralwatt || provider == .azureopenai || provider == .mimo ||
provider == .qoder || provider == .sub2api
let primaryWindow = if primaryDescriptionIsDetail {
// Some providers use resetDescription for non-reset detail
// (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import CodexBarCore
import Foundation

struct NeuralWattProviderImplementation: ProviderImplementation {
let id: UsageProvider = .neuralwatt

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "api" }
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.neuralWattAPIKey
_ = settings.tokenAccountsData(for: .neuralwatt)
}

@MainActor
func isAvailable(context: ProviderAvailabilityContext) -> Bool {
if NeuralWattSettingsReader.apiKey(environment: context.environment) != nil {
return true
}
if !context.settings.neuralWattAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return true
}
return !context.settings.tokenAccounts(for: .neuralwatt).isEmpty
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "neuralwatt-api-key",
title: "API key",
subtitle: "Stored in the CodexBar config file. Manage keys from the Neuralwatt dashboard.",
kind: .secure,
placeholder: "sk-...",
binding: context.stringBinding(\.neuralWattAPIKey),
actions: [],
isVisible: nil,
onActivate: nil),
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var neuralWattAPIKey: String {
get { self.configSnapshot.providerConfig(for: .neuralwatt)?.sanitizedAPIKey ?? "" }
set {
self.updateProviderConfig(provider: .neuralwatt) { entry in
entry.apiKey = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .neuralwatt, field: "apiKey", value: newValue)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ enum ProviderImplementationRegistry {
case .deepgram: DeepgramProviderImplementation()
case .poe: PoeProviderImplementation()
case .chutes: ChutesProviderImplementation()
case .neuralwatt: NeuralWattProviderImplementation()
case .crossmodel: CrossModelProviderImplementation()
case .clawrouter: ClawRouterProviderImplementation()
case .longcat: LongCatProviderImplementation()
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions Sources/CodexBar/UsageStore+TokenAccountLabels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import CodexBarCore
import Foundation

extension UsageStore {
func applyAccountLabel(
_ snapshot: UsageSnapshot,
provider: UsageProvider,
account: ProviderTokenAccount) -> UsageSnapshot
{
let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty else { return snapshot }
let existing = snapshot.identity(for: provider)
let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedEmail = (email?.isEmpty ?? true) ? label : email
let identity = ProviderIdentitySnapshot(
providerID: provider,
accountEmail: resolvedEmail,
accountOrganization: existing?.accountOrganization,
loginMethod: existing?.loginMethod)
return snapshot.withIdentity(identity)
}

func applyCodexVisibleAccountLabel(_ snapshot: UsageSnapshot, account: CodexVisibleAccount) -> UsageSnapshot {
let existing = snapshot.identity(for: .codex)
let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedEmail = (email?.isEmpty ?? true) ? account.email : email
let loginMethod = existing?.loginMethod ?? account.workspaceLabel
let identity = ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: resolvedEmail,
accountOrganization: existing?.accountOrganization,
loginMethod: loginMethod)
return snapshot.withIdentity(identity)
}
}
59 changes: 28 additions & 31 deletions Sources/CodexBar/UsageStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,34 @@ extension UsageStore {
return (index, account, descriptor, context)
}

if let delay = TokenAccountSupportCatalog.support(for: provider)?.minimumDelayBetweenAccountRefreshes {
var results: [TokenAccountFetchResult] = []
results.reserveCapacity(requests.count)
for request in requests {
if !results.isEmpty {
do {
try await Task.sleep(for: delay)
} catch {
for pending in requests.dropFirst(results.count) {
results.append(TokenAccountFetchResult(
index: pending.index,
account: pending.account,
outcome: ProviderFetchOutcome(
result: .failure(CancellationError()),
attempts: [])))
}
return results
}
}
let outcome = await request.descriptor.fetchOutcome(context: request.context)
results.append(TokenAccountFetchResult(
index: request.index,
account: request.account,
outcome: outcome))
}
return results
}

return await withTaskGroup(
of: TokenAccountFetchResult.self,
returning: [TokenAccountFetchResult].self)
Expand Down Expand Up @@ -1581,35 +1609,4 @@ extension UsageStore {
}
}
}

func applyAccountLabel(
_ snapshot: UsageSnapshot,
provider: UsageProvider,
account: ProviderTokenAccount) -> UsageSnapshot
{
let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty else { return snapshot }
let existing = snapshot.identity(for: provider)
let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedEmail = (email?.isEmpty ?? true) ? label : email
let identity = ProviderIdentitySnapshot(
providerID: provider,
accountEmail: resolvedEmail,
accountOrganization: existing?.accountOrganization,
loginMethod: existing?.loginMethod)
return snapshot.withIdentity(identity)
}

func applyCodexVisibleAccountLabel(_ snapshot: UsageSnapshot, account: CodexVisibleAccount) -> UsageSnapshot {
let existing = snapshot.identity(for: .codex)
let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedEmail = (email?.isEmpty ?? true) ? account.email : email
let loginMethod = existing?.loginMethod ?? account.workspaceLabel
let identity = ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: resolvedEmail,
accountOrganization: existing?.accountOrganization,
loginMethod: loginMethod)
return snapshot.withIdentity(identity)
}
}
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,7 @@ extension UsageStore {
.copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity,
.mimo, .doubao, .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus,
.commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed,
.deepgram, .poe, .chutes, .clawrouter, .longcat, .wayfinder, .sub2api, .zenmux:
.deepgram, .poe, .chutes, .neuralwatt, .clawrouter, .longcat, .wayfinder, .sub2api, .zenmux:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCLI/CLIDiagnoseCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ extension CodexBarCLI {
KiloSettingsReader.apiKey(environment: environment) != nil
case .factory:
FactorySettingsReader.apiKey(environment: environment) != nil
case .neuralwatt:
NeuralWattSettingsReader.apiKey(environment: environment) != nil
default:
false
}
Expand Down
11 changes: 10 additions & 1 deletion Sources/CodexBarCLI/CLIUsageCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,16 @@ extension CodexBarCLI {

let selections = Self.accountSelections(from: accounts)
var output = UsageCommandOutput()
for account in selections {
let accountRefreshDelay = TokenAccountSupportCatalog
.support(for: provider)?.minimumDelayBetweenAccountRefreshes
for (index, account) in selections.enumerated() {
if index > 0, let accountRefreshDelay {
do {
try await Task.sleep(for: accountRefreshDelay)
} catch {
return output
}
}
let result = await Self.fetchUsageOutput(
provider: provider,
account: account,
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ public enum ProviderConfigEnvironment {
GroqSettingsReader.apiKeyEnvironmentKey
case .llmproxy:
LLMProxySettingsReader.apiKeyEnvironmentKey
case .chutes, .poe, .litellm, .crossmodel, .clawrouter, .factory, .sub2api, .zenmux:
case .chutes, .poe, .litellm, .crossmodel, .clawrouter, .factory, .sub2api, .neuralwatt, .zenmux:
self.additionalAPIKeyEnvironmentKey(for: provider)
default:
nil
Expand All @@ -210,6 +210,8 @@ public enum ProviderConfigEnvironment {
ClawRouterSettingsReader.apiKeyEnvironmentKey
case .sub2api:
Sub2APISettingsReader.apiKeyEnvironmentKey
case .neuralwatt:
NeuralWattSettingsReader.apiKeyEnvironmentKey
case .factory:
FactorySettingsReader.apiTokenKey
case .zenmux:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "75b1c680ad3d91c9"
static let value = "c038e3bd151e9765"
}
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public enum LogCategories {
public static let minimaxWeb = "minimax-web"
public static let mimoCookie = "mimo-cookie"
public static let moonshotUsage = "moonshot-usage"
public static let neuralWattUsage = "neuralwatt-usage"
public static let notifications = "notifications"
public static let openAIWeb = "openai-web"
public static let openAIWebview = "openai-webview"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

public enum NeuralWattProviderDescriptor {
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()

static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .neuralwatt,
metadata: ProviderMetadata(
id: .neuralwatt,
displayName: "Neuralwatt",
sessionLabel: "Subscription",
weeklyLabel: "Key allowance",
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent dead Neuralwatt metric options

Because .neuralwatt is not added to SettingsStore.isBalanceOnlyProvider or otherwise special-cased, PreferencesProvidersPane.menuBarMetricPicker takes the generic branch and uses these labels to expose Primary and Secondary choices. The secondary choice is misleading for Neuralwatt because the key allowance is stored in extraRateWindows, not snapshot.secondary, so selecting it falls back to the subscription window instead; this also contradicts the new coverage test that expects only Automatic. Add a Neuralwatt-specific/balance-only picker path or expose the allowance as a real selectable lane.

Useful? React with 👍 / 👎.

opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "Subscription kWh and prepaid USD balance.",
toggleTitle: "Show Neuralwatt usage",
cliName: "neuralwatt",
defaultEnabled: false,
isPrimaryProvider: false,
usesAccountFallback: false,
browserCookieOrder: nil,
dashboardURL: "https://portal.neuralwatt.com/dashboard",
subscriptionDashboardURL: "https://portal.neuralwatt.com/dashboard",
changelogURL: nil,
statusPageURL: nil,
statusLinkURL: nil),
branding: ProviderBranding(
iconStyle: .neuralwatt,
iconResourceName: "ProviderIcon-neuralwatt",
color: ProviderColor(red: 0.22, green: 0.85, blue: 0.55)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Neuralwatt token cost history is not available via the quota API." }),
fetchPlan: .apiToken(
strategyID: "neuralwatt.api",
resolveToken: { ProviderTokenResolver.neuralWattToken(environment: $0) },
missingCredentialsError: { NeuralWattUsageError.missingCredentials },
loadUsage: { apiKey, context in
try await NeuralWattUsageFetcher.fetchUsage(
apiKey: apiKey,
environment: context.env).toUsageSnapshot()
}),
cli: ProviderCLIConfig(
name: "neuralwatt",
aliases: ["nw", "neural"],
versionDetector: nil))
}
}
Loading
Loading