diff --git a/README.md b/README.md
index 757fbf030..c68d64cf8 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift
new file mode 100644
index 000000000..89e47e1ea
--- /dev/null
+++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift
@@ -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() }),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift
new file mode 100644
index 000000000..5f0ee030f
--- /dev/null
+++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift
@@ -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() {}
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index f6a9b2a3b..ebe7db03e 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -30,6 +30,7 @@ enum ProviderImplementationRegistry {
case .kimik2: KimiK2ProviderImplementation()
case .amp: AmpProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
+ case .openrouter: OpenRouterProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg b/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg
new file mode 100644
index 000000000..c5fb0c13a
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg
@@ -0,0 +1,9 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 69491056d..65da99caa 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -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
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index a0a88371d..67071d645 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -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
}
}
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index d3d31c8fe..3c00af6b9 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -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"
diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift
new file mode 100644
index 000000000..9334241fc
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift
@@ -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."
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift
new file mode 100644
index 000000000..646b648f9
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift
@@ -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
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift
new file mode 100644
index 000000000..2aa4e680e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift
@@ -0,0 +1,233 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+/// OpenRouter credits API response
+public struct OpenRouterCreditsResponse: Decodable, Sendable {
+ public let data: OpenRouterCreditsData
+}
+
+/// OpenRouter credits data
+public struct OpenRouterCreditsData: Decodable, Sendable {
+ /// Total credits ever added to the account (in USD)
+ public let totalCredits: Double
+ /// Total credits used (in USD)
+ public let totalUsage: Double
+
+ private enum CodingKeys: String, CodingKey {
+ case totalCredits = "total_credits"
+ case totalUsage = "total_usage"
+ }
+
+ /// Remaining credits (total - usage)
+ public var balance: Double {
+ max(0, totalCredits - totalUsage)
+ }
+
+ /// Usage percentage (0-100)
+ public var usedPercent: Double {
+ guard totalCredits > 0 else { return 0 }
+ return min(100, (totalUsage / totalCredits) * 100)
+ }
+}
+
+/// OpenRouter key info API response (for rate limits)
+public struct OpenRouterKeyResponse: Decodable, Sendable {
+ public let data: OpenRouterKeyData
+}
+
+/// OpenRouter key data with rate limit info
+public struct OpenRouterKeyData: Decodable, Sendable {
+ /// Rate limit per interval
+ public let rateLimit: OpenRouterRateLimit?
+ /// Usage limits
+ public let limit: Double?
+ /// Current usage
+ public let usage: Double?
+
+ private enum CodingKeys: String, CodingKey {
+ case rateLimit = "rate_limit"
+ case limit
+ case usage
+ }
+}
+
+/// OpenRouter rate limit info
+public struct OpenRouterRateLimit: Decodable, Sendable {
+ /// Number of requests allowed
+ public let requests: Int
+ /// Interval for the rate limit (e.g., "10s", "1m")
+ public let interval: String
+}
+
+/// Complete OpenRouter usage snapshot
+public struct OpenRouterUsageSnapshot: Sendable {
+ public let totalCredits: Double
+ public let totalUsage: Double
+ public let balance: Double
+ public let usedPercent: Double
+ public let rateLimit: OpenRouterRateLimit?
+ public let updatedAt: Date
+
+ public init(
+ totalCredits: Double,
+ totalUsage: Double,
+ balance: Double,
+ usedPercent: Double,
+ rateLimit: OpenRouterRateLimit?,
+ updatedAt: Date)
+ {
+ self.totalCredits = totalCredits
+ self.totalUsage = totalUsage
+ self.balance = balance
+ self.usedPercent = usedPercent
+ self.rateLimit = rateLimit
+ self.updatedAt = updatedAt
+ }
+
+ /// Returns true if this snapshot contains valid data
+ public var isValid: Bool {
+ totalCredits >= 0
+ }
+}
+
+extension OpenRouterUsageSnapshot {
+ public func toUsageSnapshot() -> UsageSnapshot {
+ // Primary: credits usage percentage
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "Credits")
+
+ // Format balance for identity display
+ let balanceStr = String(format: "$%.2f", balance)
+ let identity = ProviderIdentitySnapshot(
+ providerID: .openrouter,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Balance: \(balanceStr)")
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ openRouterUsage: self,
+ updatedAt: updatedAt,
+ identity: identity)
+ }
+}
+
+/// Fetches usage stats from the OpenRouter API
+public struct OpenRouterUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.openRouterUsage)
+
+ /// Fetches credits usage from OpenRouter using the provided API key
+ public static func fetchUsage(
+ apiKey: String,
+ environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> OpenRouterUsageSnapshot
+ {
+ guard !apiKey.isEmpty else {
+ throw OpenRouterUsageError.invalidCredentials
+ }
+
+ let baseURL = OpenRouterSettingsReader.apiURL(environment: environment)
+ let creditsURL = baseURL.appendingPathComponent("credits")
+
+ var request = URLRequest(url: creditsURL)
+ request.httpMethod = "GET"
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw OpenRouterUsageError.networkError("Invalid response")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
+ Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorMessage)")
+ throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)")
+ }
+
+ // Log raw response for debugging
+ if let jsonString = String(data: data, encoding: .utf8) {
+ Self.log.debug("OpenRouter credits response: \(jsonString)")
+ }
+
+ do {
+ let decoder = JSONDecoder()
+ let creditsResponse = try decoder.decode(OpenRouterCreditsResponse.self, from: data)
+
+ // Optionally fetch rate limit info from /key endpoint
+ let rateLimit = await fetchRateLimit(apiKey: apiKey, baseURL: baseURL)
+
+ return OpenRouterUsageSnapshot(
+ totalCredits: creditsResponse.data.totalCredits,
+ totalUsage: creditsResponse.data.totalUsage,
+ balance: creditsResponse.data.balance,
+ usedPercent: creditsResponse.data.usedPercent,
+ rateLimit: rateLimit,
+ updatedAt: Date())
+ } catch let error as DecodingError {
+ Self.log.error("OpenRouter JSON decoding error: \(error.localizedDescription)")
+ throw OpenRouterUsageError.parseFailed(error.localizedDescription)
+ } catch let error as OpenRouterUsageError {
+ throw error
+ } catch {
+ Self.log.error("OpenRouter parsing error: \(error.localizedDescription)")
+ throw OpenRouterUsageError.parseFailed(error.localizedDescription)
+ }
+ }
+
+ /// Fetches rate limit info from /key endpoint
+ private static func fetchRateLimit(apiKey: String, baseURL: URL) async -> OpenRouterRateLimit? {
+ let keyURL = baseURL.appendingPathComponent("key")
+
+ var request = URLRequest(url: keyURL)
+ request.httpMethod = "GET"
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200
+ else {
+ return nil
+ }
+
+ let decoder = JSONDecoder()
+ let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: data)
+ return keyResponse.data.rateLimit
+ } catch {
+ Self.log.debug("Failed to fetch OpenRouter rate limit: \(error.localizedDescription)")
+ return nil
+ }
+ }
+}
+
+/// Errors that can occur during OpenRouter usage fetching
+public enum OpenRouterUsageError: LocalizedError, Sendable {
+ case invalidCredentials
+ case networkError(String)
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidCredentials:
+ "Invalid OpenRouter API credentials"
+ case let .networkError(message):
+ "OpenRouter network error: \(message)"
+ case let .apiError(message):
+ "OpenRouter API error: \(message)"
+ case let .parseFailed(message):
+ "Failed to parse OpenRouter response: \(message)"
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6aff83695..a0f84e75e 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -71,6 +71,7 @@ public enum ProviderDescriptorRegistry {
.kimik2: KimiK2ProviderDescriptor.descriptor,
.amp: AmpProviderDescriptor.descriptor,
.synthetic: SyntheticProviderDescriptor.descriptor,
+ .openrouter: OpenRouterProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index 6b978775a..78256dc86 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -45,6 +45,10 @@ public enum ProviderTokenResolver {
self.kimiK2Resolution(environment: environment)?.token
}
+ public static func openRouterToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.openRouterResolution(environment: environment)?.token
+ }
+
public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
@@ -100,6 +104,12 @@ public enum ProviderTokenResolver {
self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment))
}
+ public static func openRouterResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment))
+ }
+
private static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index a267fb953..7f4fd17e6 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -21,6 +21,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case kimik2
case amp
case synthetic
+ case openrouter
}
// swiftformat:enable sortDeclarations
@@ -44,6 +45,7 @@ public enum IconStyle: Sendable, CaseIterable {
case jetbrains
case amp
case synthetic
+ case openrouter
case combined
}
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index ca300ea9f..23b1ccce2 100644
--- a/Sources/CodexBarCore/UsageFetcher.swift
+++ b/Sources/CodexBarCore/UsageFetcher.swift
@@ -54,6 +54,7 @@ public struct UsageSnapshot: Codable, Sendable {
public let providerCost: ProviderCostSnapshot?
public let zaiUsage: ZaiUsageSnapshot?
public let minimaxUsage: MiniMaxUsageSnapshot?
+ public let openRouterUsage: OpenRouterUsageSnapshot?
public let cursorRequests: CursorRequestUsage?
public let updatedAt: Date
public let identity: ProviderIdentitySnapshot?
@@ -77,6 +78,7 @@ public struct UsageSnapshot: Codable, Sendable {
providerCost: ProviderCostSnapshot? = nil,
zaiUsage: ZaiUsageSnapshot? = nil,
minimaxUsage: MiniMaxUsageSnapshot? = nil,
+ openRouterUsage: OpenRouterUsageSnapshot? = nil,
cursorRequests: CursorRequestUsage? = nil,
updatedAt: Date,
identity: ProviderIdentitySnapshot? = nil)
@@ -87,6 +89,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.providerCost = providerCost
self.zaiUsage = zaiUsage
self.minimaxUsage = minimaxUsage
+ self.openRouterUsage = openRouterUsage
self.cursorRequests = cursorRequests
self.updatedAt = updatedAt
self.identity = identity
@@ -100,6 +103,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost)
self.zaiUsage = nil // Not persisted, fetched fresh each time
self.minimaxUsage = nil // Not persisted, fetched fresh each time
+ self.openRouterUsage = nil // Not persisted, fetched fresh each time
self.cursorRequests = nil // Not persisted, fetched fresh each time
self.updatedAt = try container.decode(Date.self, forKey: .updatedAt)
if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) {
@@ -183,6 +187,7 @@ public struct UsageSnapshot: Codable, Sendable {
providerCost: self.providerCost,
zaiUsage: self.zaiUsage,
minimaxUsage: self.minimaxUsage,
+ openRouterUsage: self.openRouterUsage,
cursorRequests: self.cursorRequests,
updatedAt: self.updatedAt,
identity: scopedIdentity)
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index d47d7d557..a2b36e847 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -99,6 +99,8 @@ enum CostUsageScanner {
return CostUsageDailyReport(data: [], summary: nil)
case .synthetic:
return CostUsageDailyReport(data: [], summary: nil)
+ case .openrouter:
+ return CostUsageDailyReport(data: [], summary: nil)
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 1634611ee..b3a46180d 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -59,6 +59,7 @@ enum ProviderChoice: String, AppEnum {
case .kimik2: return nil // Kimi K2 not yet supported in widgets
case .amp: return nil // Amp not yet supported in widgets
case .synthetic: return nil // Synthetic not yet supported in widgets
+ case .openrouter: return nil // OpenRouter not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index ed39b4506..14948622b 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -275,6 +275,7 @@ private struct ProviderSwitchChip: View {
case .kimik2: "Kimi K2"
case .amp: "Amp"
case .synthetic: "Synthetic"
+ case .openrouter: "OpenRouter"
}
}
}
@@ -605,6 +606,8 @@ enum WidgetColors {
Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red
case .synthetic:
Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal
+ case .openrouter:
+ Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
}
}
}
diff --git a/docs/claude.md b/docs/claude.md
index 564672dda..f1cfa7baf 100644
--- a/docs/claude.md
+++ b/docs/claude.md
@@ -103,3 +103,12 @@ Usage source picker:
`Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift`
- Cost usage: `Sources/CodexBarCore/CostUsageFetcher.swift`,
`Sources/CodexBarCore/Vendored/CostUsage/*`
+
+
+
+# Recent Activity
+
+
+
+*No recent activity*
+
\ No newline at end of file
diff --git a/docs/openrouter.md b/docs/openrouter.md
new file mode 100644
index 000000000..dea631dec
--- /dev/null
+++ b/docs/openrouter.md
@@ -0,0 +1,54 @@
+# OpenRouter Provider
+
+[OpenRouter](https://openrouter.ai) is a unified API that provides access to multiple AI models from different providers (OpenAI, Anthropic, Google, Meta, and more) through a single endpoint.
+
+## Authentication
+
+OpenRouter uses API key authentication. Get your API key from [OpenRouter Settings](https://openrouter.ai/settings/keys).
+
+### Environment Variable
+
+Set the `OPENROUTER_API_KEY` environment variable:
+
+```bash
+export OPENROUTER_API_KEY="sk-or-v1-..."
+```
+
+### Settings
+
+You can also configure the API key in CodexBar Settings → Providers → OpenRouter.
+
+## Data Source
+
+The OpenRouter provider fetches usage data from two API endpoints:
+
+1. **Credits API** (`/api/v1/credits`): Returns total credits purchased and total usage. The balance is calculated as `total_credits - total_usage`.
+
+2. **Key API** (`/api/v1/key`): Returns rate limit information for your API key.
+
+## Display
+
+The OpenRouter menu card shows:
+
+- **Primary meter**: Credit usage percentage (how much of your purchased credits have been used)
+- **Balance**: Displayed in the identity section as "Balance: $X.XX"
+
+## CLI Usage
+
+```bash
+codexbar --provider openrouter
+codexbar -p or # alias
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `OPENROUTER_API_KEY` | Your OpenRouter API key (required) |
+| `OPENROUTER_API_URL` | Override the base API URL (optional, defaults to `https://openrouter.ai/api/v1`) |
+
+## Notes
+
+- Credit values are cached on OpenRouter's side and may be up to 60 seconds stale
+- OpenRouter uses a credit-based billing system where you pre-purchase credits
+- Rate limits depend on your credit balance (10+ credits = 1000 free model requests/day)
diff --git a/docs/providers.md b/docs/providers.md
index d25dfb887..3546cc2bd 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -1,5 +1,5 @@
---
-summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -34,6 +34,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). |
| JetBrains AI | Local XML quota file (`local`). |
| Amp | Web settings page via browser cookies (`web`). |
+| OpenRouter | API token (Keychain/env) → credits API (`api`). |
## Codex
- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -139,4 +140,13 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Parses Amp Free usage from the settings HTML.
- Status: none yet.
- Details: `docs/amp.md`.
+
+## OpenRouter
+- API token from Keychain or `OPENROUTER_API_KEY` env var.
+- Credits endpoint: `https://openrouter.ai/api/v1/credits` (returns total credits purchased and usage).
+- Key info endpoint: `https://openrouter.ai/api/v1/key` (returns rate limit info).
+- Override base URL with `OPENROUTER_API_URL` env var.
+- Status: `https://status.openrouter.ai` (link only, no auto-polling yet).
+- Details: `docs/openrouter.md`.
+
See also: `docs/provider.md` for architecture notes.