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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.43.1 — Unreleased

### Added
- ai&: add 30-day organization spend from the documented analytics summary API using org-scoped API keys.
- ZenMux: add Management API usage with five-hour and weekly quotas, subscription expiry, and USD PAYG balance. Thanks @kays0x!

### Fixed
Expand Down
2 changes: 1 addition & 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. 60 providers." width="100%" /></a>
<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 61 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
4 changes: 3 additions & 1 deletion Sources/CodexBar/MenuCardView+Costs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,9 @@ extension UsageMenuCardView.Model {
percentLine: nil)
}

if provider == .openai || provider == .claude || provider == .litellm, cost.limit <= 0 {
if provider == .openai || provider == .claude || provider == .litellm || provider == .aiand,
cost.limit <= 0
{
let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days")
return ProviderCostSection(
Expand Down
52 changes: 52 additions & 0 deletions Sources/CodexBar/Providers/AiAnd/AiAndProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AppKit
import CodexBarCore
import Foundation

struct AiAndProviderImplementation: ProviderImplementation {
let id: UsageProvider = .aiand

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

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.aiAndAPIKey
}

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

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "aiand-api-key",
title: "API key",
subtitle: "Stored in ~/.codexbar/config.json. Create a key in the ai& console (shown once).",
kind: .secure,
placeholder: "sk-…",
binding: context.stringBinding(\.aiAndAPIKey),
actions: [
ProviderSettingsActionDescriptor(
id: "aiand-open-console",
title: "Open ai& Console",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://console.aiand.com") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: nil,
onActivate: nil),
]
}
}
14 changes: 14 additions & 0 deletions Sources/CodexBar/Providers/AiAnd/AiAndSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var aiAndAPIKey: String {
get { self.configSnapshot.providerConfig(for: .aiand)?.sanitizedAPIKey ?? "" }
set {
self.updateProviderConfig(provider: .aiand) { entry in
entry.apiKey = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .aiand, field: "apiKey", value: newValue)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ enum ProviderImplementationRegistry {
case .sub2api: Sub2APIProviderImplementation()
case .wayfinder: WayfinderProviderImplementation()
case .zenmux: ZenMuxProviderImplementation()
case .aiand: AiAndProviderImplementation()
}
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-aiand.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,7 @@ extension UsageStore {
.wayfinder: "Wayfinder debug log not yet implemented",
.sub2api: "sub2api debug log not yet implemented",
.zenmux: "ZenMux debug log not yet implemented",
.aiand: "ai& debug log not yet implemented",
]
let buildText = {
switch provider {
Expand Down Expand Up @@ -1046,7 +1047,7 @@ extension UsageStore {
.copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .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, .clawrouter, .longcat, .wayfinder, .sub2api, .zenmux, .aiand:
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 @@ -227,6 +227,8 @@ extension CodexBarCLI {
ChutesSettingsReader.apiKey(environment: environment) != nil
case .zenmux:
ZenMuxSettingsReader.managementAPIKey(environment: environment) != nil
case .aiand:
AiAndSettingsReader.apiKey(environment: environment) != nil
case .crof:
CrofSettingsReader.apiKey(environment: environment) != nil
case .deepgram:
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 @@ -185,7 +185,7 @@ public enum ProviderConfigEnvironment {
GroqSettingsReader.apiKeyEnvironmentKey
case .llmproxy:
LLMProxySettingsReader.apiKeyEnvironmentKey
case .chutes, .poe, .litellm, .clawrouter, .factory, .sub2api, .zenmux:
case .chutes, .poe, .litellm, .clawrouter, .factory, .sub2api, .zenmux, .aiand:
self.additionalAPIKeyEnvironmentKey(for: provider)
default:
nil
Expand All @@ -208,6 +208,8 @@ public enum ProviderConfigEnvironment {
FactorySettingsReader.apiTokenKey
case .zenmux:
ZenMuxSettingsReader.managementAPIKeyEnvironmentKey
case .aiand:
AiAndSettingsReader.apiKeyEnvironmentKey
default:
nil
}
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 = "8be945d1c2519e9b"
static let value = "d5f1c95adfaaf1b6"
}
61 changes: 61 additions & 0 deletions Sources/CodexBarCore/Providers/AiAnd/AiAndProviderDescriptor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

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

static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .aiand,
metadata: ProviderMetadata(
id: .aiand,
displayName: "ai&",
sessionLabel: "Spend",
weeklyLabel: "Spend",
opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show ai& usage",
cliName: "aiand",
defaultEnabled: false,
dashboardURL: "https://console.aiand.com",
statusPageURL: nil),
branding: ProviderBranding(
iconStyle: .aiand,
iconResourceName: "ProviderIcon-aiand",
color: ProviderColor(red: 226 / 255, green: 92 / 255, blue: 43 / 255)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "ai& spend is reported by the analytics summary API." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .api],
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AiAndAPIFetchStrategy()] })),
cli: ProviderCLIConfig(
name: "aiand",
aliases: ["ai&", "ai-and"],
versionDetector: nil))
}
}

struct AiAndAPIFetchStrategy: ProviderFetchStrategy {
let id = "aiand.api"
let kind: ProviderFetchKind = .apiToken

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
AiAndSettingsReader.apiKey(environment: context.env) != nil
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
guard let credential = AiAndSettingsReader.apiKey(environment: context.env) else {
throw AiAndUsageError.notConfigured
}
let usage = try await AiAndUsageFetcher.fetchUsage(credential)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "api")
}

func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
24 changes: 24 additions & 0 deletions Sources/CodexBarCore/Providers/AiAnd/AiAndSettingsReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

public enum AiAndSettingsReader {
public static let apiKeyEnvironmentKey = "AIAND_API_KEY"

public static func apiKey(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
self.cleaned(environment[self.apiKeyEnvironmentKey])
}

static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
(value.hasPrefix("'") && value.hasSuffix("'"))
{
value = String(value.dropFirst().dropLast())
}
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
117 changes: 117 additions & 0 deletions Sources/CodexBarCore/Providers/AiAnd/AiAndUsageFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public enum AiAndUsageError: LocalizedError, Sendable, Equatable {
case notConfigured
case invalidAPIKey
case insufficientCredits
case rateLimited
case apiError(Int)
case parseFailed(String)

public var errorDescription: String? {
switch self {
case .notConfigured:
"Missing ai& API key. Add one in Settings or set AIAND_API_KEY."
case .invalidAPIKey:
"ai& rejected the API key. Create a new key at console.aiand.com and update Settings."
case .insufficientCredits:
"ai& reports the organization is out of credits. Top up at console.aiand.com."
case .rateLimited:
"ai& rate limit exceeded. Usage will refresh on the next cycle."
case let .apiError(statusCode):
"ai& analytics API returned HTTP \(statusCode)."
case let .parseFailed(message):
"Could not parse ai& usage: \(message)"
}
}
}

public struct AiAndUsageSnapshot: Sendable, Equatable {
public let last30DaysCostUSD: Double
public let updatedAt: Date

public init(last30DaysCostUSD: Double, updatedAt: Date) {
self.last30DaysCostUSD = last30DaysCostUSD
self.updatedAt = updatedAt
}

public func toUsageSnapshot() -> UsageSnapshot {
// ai& is prepaid with no quota windows; spend is the only documented usage
// signal, so no RateWindows are synthesized. limit 0 means "no cap".
UsageSnapshot(
primary: nil,
secondary: nil,
providerCost: ProviderCostSnapshot(
used: self.last30DaysCostUSD,
limit: 0,
currencyCode: "USD",
period: "Last 30 days",
updatedAt: self.updatedAt),
updatedAt: self.updatedAt,
identity: nil,
dataConfidence: .exact)
}
}

public enum AiAndUsageFetcher {
static let summaryURL = URL(string: "https://api.aiand.com/analytics/summary?range=30days")!
private static let requestTimeoutSeconds: TimeInterval = 15

public static func fetchUsage(
_ rawCredential: String,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
now: Date = Date()) async throws -> AiAndUsageSnapshot
{
guard let credential = AiAndSettingsReader.cleaned(rawCredential) else {
throw AiAndUsageError.notConfigured
}
var request = URLRequest(url: self.summaryURL)
request.httpMethod = "GET"
request.timeoutInterval = self.requestTimeoutSeconds
request.setValue("Bearer \(credential)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")

let response = try await transport.response(for: request)
guard (200..<300).contains(response.statusCode) else {
throw self.error(statusCode: response.statusCode)
}
return try self.parseSummary(response.data, now: now)
}

private static func parseSummary(_ data: Data, now: Date) throws -> AiAndUsageSnapshot {
let summary: SummaryPayload
do {
summary = try JSONDecoder().decode(SummaryPayload.self, from: data)
} catch {
throw AiAndUsageError.parseFailed(error.localizedDescription)
}
guard summary.costUSD.isFinite else {
throw AiAndUsageError.parseFailed("cost_usd is not a finite number")
}
return AiAndUsageSnapshot(last30DaysCostUSD: summary.costUSD, updatedAt: now)
}

private static func error(statusCode: Int) -> AiAndUsageError {
switch statusCode {
case 401:
.invalidAPIKey
case 402:
.insufficientCredits
case 429:
.rateLimited
default:
.apiError(statusCode)
}
}
}

private struct SummaryPayload: Decodable {
let costUSD: Double

enum CodingKeys: String, CodingKey {
case costUSD = "cost_usd"
}
}
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Providers/ProviderDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public enum ProviderDescriptorRegistry {
.sub2api: Sub2APIProviderDescriptor.descriptor,
.wayfinder: WayfinderProviderDescriptor.descriptor,
.zenmux: ZenMuxProviderDescriptor.descriptor,
.aiand: AiAndProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Providers/Providers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case sub2api
case wayfinder
case zenmux
case aiand
}

// swiftformat:enable sortDeclarations
Expand Down Expand Up @@ -126,6 +127,7 @@ public enum IconStyle: String, Sendable, CaseIterable {
case sub2api
case wayfinder
case zenmux
case aiand
case combined
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ enum CostUsageScanner {
.ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana,
.abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun,
.bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .clawrouter,
.longcat, .sub2api, .wayfinder, .zenmux:
.longcat, .sub2api, .wayfinder, .zenmux, .aiand:
return emptyReport
}
}
Expand Down
Loading