Skip to content
Merged
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
@@ -1,6 +1,6 @@
# CodexBar 🎚️ - May your tokens never run out.

Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.

<img src="codexbar.png" alt="CodexBar menu screenshot" width="520" />

Expand Down Expand Up @@ -46,6 +46,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [Augment](docs/augment.md) — Browser cookie-based authentication with automatic session keepalive; credits tracking and usage monitoring.
- [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking.
- [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking.
- [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers.
- Open to new providers: [provider authoring guide](docs/provider.md).

## Icon & Screenshot
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import AppKit
import CodexBarCore
import CodexBarMacroSupport
import Foundation
import SwiftUI

@ProviderImplementationRegistration
struct OpenRouterProviderImplementation: ProviderImplementation {
let id: UsageProvider = .openrouter

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

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

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
_ = context
return nil
}

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

@MainActor
func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
[]
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "openrouter-api-key",
title: "API key",
subtitle: "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys.",
kind: .secure,
placeholder: "sk-or-v1-...",
binding: context.stringBinding(\.openRouterAPIToken),
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 openRouterAPIToken: String {
get { self.configSnapshot.providerConfig(for: .openrouter)?.sanitizedAPIKey ?? "" }
set {
self.updateProviderConfig(provider: .openrouter) { entry in
entry.apiKey = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .openrouter, field: "apiKey", value: newValue)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum ProviderImplementationRegistry {
private static let lock = NSLock()
private static let store = Store()

// swiftlint:disable:next cyclomatic_complexity
private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) {
switch provider {
case .codex: CodexProviderImplementation()
Expand All @@ -31,6 +32,7 @@ enum ProviderImplementationRegistry {
case .amp: AmpProviderImplementation()
case .ollama: OllamaProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
}
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-openrouter.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 1 addition & 9 deletions Sources/CodexBar/UsageStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,6 @@ extension UsageStore {
accountEmail: resolvedEmail,
accountOrganization: existing?.accountOrganization,
loginMethod: existing?.loginMethod)
return UsageSnapshot(
primary: snapshot.primary,
secondary: snapshot.secondary,
tertiary: snapshot.tertiary,
providerCost: snapshot.providerCost,
zaiUsage: snapshot.zaiUsage,
cursorRequests: snapshot.cursorRequests,
updatedAt: snapshot.updatedAt,
identity: identity)
return snapshot.withIdentity(identity)
}
}
22 changes: 22 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,15 @@ extension UsageStore {
let ampCookieHeader = self.settings.ampCookieHeader
let ollamaCookieSource = self.settings.ollamaCookieSource
let ollamaCookieHeader = self.settings.ollamaCookieHeader
let processEnvironment = ProcessInfo.processInfo.environment
let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey
let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let openRouterHasEnvToken = OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil
let openRouterEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride(
base: processEnvironment,
provider: .openrouter,
config: self.settings.providerConfig(for: .openrouter))
return await Task.detached(priority: .utility) { () -> String in
let unimplementedDebugLogMessages: [UsageProvider: String] = [
.gemini: "Gemini debug log not yet implemented",
Expand Down Expand Up @@ -1210,6 +1219,19 @@ extension UsageStore {
text = await self.debugOllamaLog(
ollamaCookieSource: ollamaCookieSource,
ollamaCookieHeader: ollamaCookieHeader)
case .openrouter:
let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment)
let hasAny = resolution != nil
let source: String = if resolution == nil {
"none"
} else if openRouterHasConfigToken, openRouterHasEnvToken {
"settings-config (overrides env)"
} else if openRouterHasConfigToken {
"settings-config"
} else {
resolution?.source.rawValue ?? "environment"
}
text = "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .warp:
let resolution = ProviderTokenResolver.warpResolution()
let hasAny = resolution != nil
Expand Down
12 changes: 2 additions & 10 deletions Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp:
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
}
Expand Down Expand Up @@ -222,15 +222,7 @@ struct TokenAccountCLIContext {
accountEmail: resolvedEmail,
accountOrganization: existing?.accountOrganization,
loginMethod: existing?.loginMethod)
return UsageSnapshot(
primary: snapshot.primary,
secondary: snapshot.secondary,
tertiary: snapshot.tertiary,
providerCost: snapshot.providerCost,
zaiUsage: snapshot.zaiUsage,
cursorRequests: snapshot.cursorRequests,
updatedAt: snapshot.updatedAt,
identity: identity)
return snapshot.withIdentity(identity)
}

func effectiveSourceMode(
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public enum ProviderConfigEnvironment {
if let key = WarpSettingsReader.apiKeyEnvironmentKeys.first {
env[key] = apiKey
}
case .openrouter:
env[OpenRouterSettingsReader.envKey] = apiKey
default:
break
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public enum LogCategories {
public static let openAIWebview = "openai-webview"
public static let ollama = "ollama"
public static let opencodeUsage = "opencode-usage"
public static let openRouterUsage = "openrouter-usage"
public static let providerDetection = "provider-detection"
public static let providers = "providers"
public static let sessionQuota = "sessionQuota"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import CodexBarMacroSupport
import Foundation

@ProviderDescriptorRegistration
@ProviderDescriptorDefinition
public enum OpenRouterProviderDescriptor {
static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .openrouter,
metadata: ProviderMetadata(
id: .openrouter,
displayName: "OpenRouter",
sessionLabel: "Credits",
weeklyLabel: "Usage",
opusLabel: nil,
supportsOpus: false,
supportsCredits: true,
creditsHint: "Credit balance from OpenRouter API",
toggleTitle: "Show OpenRouter usage",
cliName: "openrouter",
defaultEnabled: false,
isPrimaryProvider: false,
usesAccountFallback: false,
dashboardURL: "https://openrouter.ai/settings/credits",
statusPageURL: nil,
statusLinkURL: "https://status.openrouter.ai"),
branding: ProviderBranding(
iconStyle: .openrouter,
iconResourceName: "ProviderIcon-openrouter",
color: ProviderColor(red: 111 / 255, green: 66 / 255, blue: 193 / 255)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "OpenRouter cost summary is not yet supported." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .api],
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenRouterAPIFetchStrategy()] })),
cli: ProviderCLIConfig(
name: "openrouter",
aliases: ["or"],
versionDetector: nil))
}
}

struct OpenRouterAPIFetchStrategy: ProviderFetchStrategy {
let id: String = "openrouter.api"
let kind: ProviderFetchKind = .apiToken

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

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
guard let apiKey = Self.resolveToken(environment: context.env) else {
throw OpenRouterSettingsError.missingToken
}
let usage = try await OpenRouterUsageFetcher.fetchUsage(
apiKey: apiKey,
environment: context.env)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "api")
}

func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}

private static func resolveToken(environment: [String: String]) -> String? {
ProviderTokenResolver.openRouterToken(environment: environment)
}
}

/// Errors related to OpenRouter settings
public enum OpenRouterSettingsError: LocalizedError, Sendable {
case missingToken

public var errorDescription: String? {
switch self {
case .missingToken:
"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings."
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

/// Reads OpenRouter settings from environment variables
public enum OpenRouterSettingsReader {
/// Environment variable key for OpenRouter API token
public static let envKey = "OPENROUTER_API_KEY"

/// Returns the API token from environment if present and non-empty
public static func apiToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
self.cleaned(environment[self.envKey])
}

/// Returns the API URL, defaulting to production endpoint
public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
if let override = environment["OPENROUTER_API_URL"],
let url = URL(string: cleaned(override) ?? "")
{
return url
}
return URL(string: "https://openrouter.ai/api/v1")!
}

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.removeFirst()
value.removeLast()
}

value = value.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
Loading