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
@@ -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,56 @@
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
}
context.settings.ensureOpenRouterAPITokenLoaded()
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: { context.settings.ensureOpenRouterAPITokenLoaded() }),
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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)
}
}

func ensureOpenRouterAPITokenLoaded() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum ProviderImplementationRegistry {
case .kimik2: KimiK2ProviderImplementation()
case .amp: AmpProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
}
}

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.
7 changes: 7 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,13 @@ extension UsageStore {
let text = "JetBrains AI debug log not yet implemented"
await MainActor.run { self.probeLogs[.jetbrains] = text }
return text
case .openrouter:
let resolution = ProviderTokenResolver.openRouterResolution()
let hasAny = resolution != nil
let source = resolution?.source.rawValue ?? "none"
let text = "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
await MainActor.run { self.probeLogs[.openrouter] = text }
return text
}
}.value
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic:
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter:
return nil
}
}
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 @@ -41,6 +41,7 @@ public enum LogCategories {
public static let openAIWeb = "openai-web"
public static let openAIWebview = "openai-webview"
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)
Comment on lines +68 to +69

Choose a reason for hiding this comment

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

P1 Badge Use stored API key for OpenRouter fetch

This strategy only resolves the token from context.env via ProviderTokenResolver.openRouterToken, which reads OPENROUTER_API_KEY. The UI stores the key in config.json (OpenRouterSettingsStore), but ProviderConfigEnvironment.applyAPIKeyOverride doesn’t map .openrouter, so the saved key never reaches context.env. As a result, users who configure the API key in Settings (and don’t export the env var) will see the provider marked available but every fetch fails with missingToken.

Useful? React with 👍 / 👎.

}
}

/// 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? {
cleaned(environment[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