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..b604dcf6c
--- /dev/null
+++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift
@@ -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),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift
new file mode 100644
index 000000000..130cdf3dd
--- /dev/null
+++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift
@@ -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)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 8754e595f..1cb530ce8 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -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()
@@ -31,6 +32,7 @@ enum ProviderImplementationRegistry {
case .amp: AmpProviderImplementation()
case .ollama: OllamaProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
+ case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
}
}
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+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift
index 7127a1233..3e55ffa9f 100644
--- a/Sources/CodexBar/UsageStore+TokenAccounts.swift
+++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift
@@ -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)
}
}
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 84280b3c0..20bb7491f 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -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",
@@ -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
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 2487a2b1b..c1617905e 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -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
}
}
@@ -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(
diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
index 1969ba6ab..9a72d340a 100644
--- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
+++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
@@ -25,6 +25,8 @@ public enum ProviderConfigEnvironment {
if let key = WarpSettingsReader.apiKeyEnvironmentKeys.first {
env[key] = apiKey
}
+ case .openrouter:
+ env[OpenRouterSettingsReader.envKey] = apiKey
default:
break
}
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 5904ce7ad..37a7726ef 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -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"
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..e5e3f4d78
--- /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? {
+ 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
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift
new file mode 100644
index 000000000..15712b360
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift
@@ -0,0 +1,361 @@
+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, self.totalCredits - self.totalUsage)
+ }
+
+ /// Usage percentage (0-100)
+ public var usedPercent: Double {
+ guard self.totalCredits > 0 else { return 0 }
+ return min(100, (self.totalUsage / self.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 {
+ self.totalCredits >= 0
+ }
+}
+
+extension OpenRouterUsageSnapshot {
+ public func toUsageSnapshot() -> UsageSnapshot {
+ // Primary: credits usage percentage
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: nil)
+
+ // 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: self.updatedAt,
+ identity: identity)
+ }
+}
+
+/// Fetches usage stats from the OpenRouter API
+public struct OpenRouterUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.openRouterUsage)
+ private static let rateLimitTimeoutSeconds: TimeInterval = 1.0
+ private static let creditsRequestTimeoutSeconds: TimeInterval = 15
+ private static let maxErrorBodyLength = 240
+ private static let maxDebugErrorBodyLength = 2000
+ private static let debugFullErrorBodiesEnvKey = "CODEXBAR_DEBUG_OPENROUTER_ERROR_BODIES"
+ private static let httpRefererEnvKey = "OPENROUTER_HTTP_REFERER"
+ private static let clientTitleEnvKey = "OPENROUTER_X_TITLE"
+ private static let defaultClientTitle = "CodexBar"
+
+ /// 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")
+ request.timeoutInterval = Self.creditsRequestTimeoutSeconds
+ if let referer = Self.sanitizedHeaderValue(environment[self.httpRefererEnvKey]) {
+ request.setValue(referer, forHTTPHeaderField: "HTTP-Referer")
+ }
+ let title = Self.sanitizedHeaderValue(environment[self.clientTitleEnvKey]) ?? Self.defaultClientTitle
+ request.setValue(title, forHTTPHeaderField: "X-Title")
+
+ 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 errorSummary = LogRedactor.redact(Self.sanitizedResponseBodySummary(data))
+ if Self.debugFullErrorBodiesEnabled(environment: environment),
+ let debugBody = Self.redactedDebugResponseBody(data)
+ {
+ Self.log.debug("OpenRouter non-200 body (redacted): \(LogRedactor.redact(debugBody))")
+ }
+ Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorSummary)")
+ throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode)")
+ }
+
+ do {
+ let decoder = JSONDecoder()
+ let creditsResponse = try decoder.decode(OpenRouterCreditsResponse.self, from: data)
+
+ // Optionally fetch rate limit info from /key endpoint, but keep this bounded so
+ // credits updates are not blocked by a slow or unavailable secondary endpoint.
+ let rateLimit = await fetchRateLimit(
+ apiKey: apiKey,
+ baseURL: baseURL,
+ timeoutSeconds: Self.rateLimitTimeoutSeconds)
+
+ 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,
+ timeoutSeconds: TimeInterval) async -> OpenRouterRateLimit?
+ {
+ let timeout = max(0.1, timeoutSeconds)
+ let timeoutNanoseconds = UInt64(timeout * 1_000_000_000)
+
+ return await withTaskGroup(of: OpenRouterRateLimit?.self) { group in
+ group.addTask {
+ await Self.fetchRateLimitRequest(
+ apiKey: apiKey,
+ baseURL: baseURL,
+ timeoutSeconds: timeout)
+ }
+ group.addTask {
+ do {
+ try await Task.sleep(nanoseconds: timeoutNanoseconds)
+ } catch {
+ // Cancelled because the /key request finished first.
+ return nil
+ }
+ guard !Task.isCancelled else { return nil }
+ Self.log.debug("OpenRouter /key enrichment timed out after \(timeout)s")
+ return nil
+ }
+
+ let result = await group.next()
+ group.cancelAll()
+ if let result {
+ return result
+ }
+ return nil
+ }
+ }
+
+ private static func fetchRateLimitRequest(
+ apiKey: String,
+ baseURL: URL,
+ timeoutSeconds: TimeInterval) 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")
+ request.timeoutInterval = timeoutSeconds
+
+ 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
+ }
+ }
+
+ private static func debugFullErrorBodiesEnabled(environment: [String: String]) -> Bool {
+ environment[self.debugFullErrorBodiesEnvKey] == "1"
+ }
+
+ private static func sanitizedHeaderValue(_ raw: String?) -> String? {
+ OpenRouterSettingsReader.cleaned(raw)
+ }
+
+ private static func sanitizedResponseBodySummary(_ data: Data) -> String {
+ guard !data.isEmpty else { return "empty body" }
+
+ guard let rawBody = String(bytes: data, encoding: .utf8) else {
+ return "non-text body (\(data.count) bytes)"
+ }
+
+ let body = Self.redactSensitiveBodyContent(rawBody)
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard !body.isEmpty else { return "non-text body (\(data.count) bytes)" }
+ guard body.count > Self.maxErrorBodyLength else { return body }
+
+ let index = body.index(body.startIndex, offsetBy: Self.maxErrorBodyLength)
+ return "\(body[.. String? {
+ guard let rawBody = String(bytes: data, encoding: .utf8) else { return nil }
+
+ let body = Self.redactSensitiveBodyContent(rawBody)
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !body.isEmpty else { return nil }
+ guard body.count > Self.maxDebugErrorBodyLength else { return body }
+
+ let index = body.index(body.startIndex, offsetBy: Self.maxDebugErrorBodyLength)
+ return "\(body[.. String {
+ let replacements: [(String, String)] = [
+ (#"(?i)(bearer\s+)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"),
+ (#"(?i)(sk-or-v1-)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"),
+ (
+ #"(?i)(\"(?:api_?key|authorization|token|access_token|refresh_token)\"\s*:\s*\")([^\"]+)(\")"#,
+ "$1[REDACTED]$3"),
+ (
+ #"(?i)((?:api_?key|authorization|token|access_token|refresh_token)\s*[=:]\s*)([^,\s]+)"#,
+ "$1[REDACTED]"),
+ ]
+
+ return replacements.reduce(text) { partial, replacement in
+ partial.replacingOccurrences(
+ of: replacement.0,
+ with: replacement.1,
+ options: .regularExpression)
+ }
+ }
+
+ #if DEBUG
+ static func _sanitizedResponseBodySummaryForTesting(_ body: String) -> String {
+ self.sanitizedResponseBodySummary(Data(body.utf8))
+ }
+
+ static func _redactedDebugResponseBodyForTesting(_ body: String) -> String? {
+ self.redactedDebugResponseBody(Data(body.utf8))
+ }
+ #endif
+}
+
+/// 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 f7ed0884b..0e18e2c3f 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -72,6 +72,7 @@ public enum ProviderDescriptorRegistry {
.amp: AmpProviderDescriptor.descriptor,
.ollama: OllamaProviderDescriptor.descriptor,
.synthetic: SyntheticProviderDescriptor.descriptor,
+ .openrouter: OpenRouterProviderDescriptor.descriptor,
.warp: WarpProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index 4134d67bf..7c746cf2b 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -49,6 +49,10 @@ public enum ProviderTokenResolver {
self.warpResolution(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?
{
@@ -110,6 +114,12 @@ public enum ProviderTokenResolver {
self.resolveEnv(WarpSettingsReader.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 418535b52..b6d75ebb1 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -23,6 +23,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case ollama
case synthetic
case warp
+ case openrouter
}
// swiftformat:enable sortDeclarations
@@ -48,6 +49,7 @@ public enum IconStyle: Sendable, CaseIterable {
case ollama
case synthetic
case warp
+ case openrouter
case combined
}
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index ca300ea9f..9834b00bf 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) {
@@ -172,20 +176,26 @@ public struct UsageSnapshot: Codable, Sendable {
self.identity(for: provider)?.loginMethod
}
- public func scoped(to provider: UsageProvider) -> UsageSnapshot {
- guard let identity else { return self }
- let scopedIdentity = identity.scoped(to: provider)
- if scopedIdentity.providerID == identity.providerID { return self }
- return UsageSnapshot(
+ /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data.
+ public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot {
+ UsageSnapshot(
primary: self.primary,
secondary: self.secondary,
tertiary: self.tertiary,
providerCost: self.providerCost,
zaiUsage: self.zaiUsage,
minimaxUsage: self.minimaxUsage,
+ openRouterUsage: self.openRouterUsage,
cursorRequests: self.cursorRequests,
updatedAt: self.updatedAt,
- identity: scopedIdentity)
+ identity: identity)
+ }
+
+ public func scoped(to provider: UsageProvider) -> UsageSnapshot {
+ guard let identity else { return self }
+ let scopedIdentity = identity.scoped(to: provider)
+ if scopedIdentity.providerID == identity.providerID { return self }
+ return self.withIdentity(scopedIdentity)
}
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 4546bd07f..f5cb75f5e 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -71,7 +71,7 @@ enum CostUsageScanner {
}
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kiro, .kimi, .kimik2,
- .augment, .jetbrains, .amp, .ollama, .synthetic, .warp:
+ .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 7b094bf46..5b88abbdd 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -39,6 +39,7 @@ enum ProviderChoice: String, AppEnum {
}
}
+ // swiftlint:disable:next cyclomatic_complexity
init?(provider: UsageProvider) {
switch provider {
case .codex: self = .codex
@@ -60,6 +61,7 @@ enum ProviderChoice: String, AppEnum {
case .amp: return nil // Amp not yet supported in widgets
case .ollama: return nil // Ollama 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
case .warp: return nil // Warp not yet supported in widgets
}
}
@@ -214,7 +216,8 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider {
private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
let enabled = snapshot.enabledProviders
let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled
- return providers.isEmpty ? [.codex] : providers
+ let supported = providers.filter { ProviderChoice(provider: $0) != nil }
+ return supported.isEmpty ? [.codex] : supported
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 6e3ea3528..7ad1064e5 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -276,6 +276,7 @@ private struct ProviderSwitchChip: View {
case .amp: "Amp"
case .ollama: "Ollama"
case .synthetic: "Synthetic"
+ case .openrouter: "OpenRouter"
case .warp: "Warp"
}
}
@@ -569,6 +570,7 @@ private struct UsageHistoryChart: View {
}
enum WidgetColors {
+ // swiftlint:disable:next cyclomatic_complexity
static func color(for provider: UsageProvider) -> Color {
switch provider {
case .codex:
@@ -609,6 +611,8 @@ enum WidgetColors {
Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal
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
case .warp:
Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
}
diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift
new file mode 100644
index 000000000..8ce66e69e
--- /dev/null
+++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift
@@ -0,0 +1,155 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct OpenRouterUsageStatsTests {
+ @Test
+ func toUsageSnapshot_doesNotSetSyntheticResetDescription() {
+ let snapshot = OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 45.3895596325,
+ balance: 4.6104403675,
+ usedPercent: 90.779119265,
+ rateLimit: nil,
+ updatedAt: Date(timeIntervalSince1970: 1_739_841_600))
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary?.resetsAt == nil)
+ #expect(usage.primary?.resetDescription == nil)
+ }
+
+ @Test
+ func sanitizers_redactSensitiveTokenShapes() {
+ let body = """
+ {"error":"bad token sk-or-v1-abc123","token":"secret-token","authorization":"Bearer sk-or-v1-xyz789"}
+ """
+
+ let summary = OpenRouterUsageFetcher._sanitizedResponseBodySummaryForTesting(body)
+ let debugBody = OpenRouterUsageFetcher._redactedDebugResponseBodyForTesting(body)
+
+ #expect(summary.contains("sk-or-v1-[REDACTED]"))
+ #expect(summary.contains("\"token\":\"[REDACTED]\""))
+ #expect(!summary.contains("secret-token"))
+ #expect(!summary.contains("sk-or-v1-abc123"))
+
+ #expect(debugBody?.contains("sk-or-v1-[REDACTED]") == true)
+ #expect(debugBody?.contains("\"token\":\"[REDACTED]\"") == true)
+ #expect(debugBody?.contains("secret-token") == false)
+ #expect(debugBody?.contains("sk-or-v1-xyz789") == false)
+ }
+
+ @Test
+ func non200FetchThrowsGenericHTTPErrorWithoutBodyDetails() async throws {
+ let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self)
+ }
+ OpenRouterStubURLProtocol.handler = nil
+ }
+
+ OpenRouterStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let body = #"{"error":"invalid sk-or-v1-super-secret","token":"dont-leak-me"}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 401)
+ }
+
+ do {
+ _ = try await OpenRouterUsageFetcher.fetchUsage(
+ apiKey: "sk-or-v1-test",
+ environment: ["OPENROUTER_API_URL": "https://openrouter.test/api/v1"])
+ Issue.record("Expected OpenRouterUsageError.apiError")
+ } catch let error as OpenRouterUsageError {
+ guard case let .apiError(message) = error else {
+ Issue.record("Expected apiError, got: \(error)")
+ return
+ }
+ #expect(message == "HTTP 401")
+ #expect(!message.contains("dont-leak-me"))
+ #expect(!message.contains("sk-or-v1-super-secret"))
+ }
+ }
+
+ @Test
+ func fetchUsage_setsCreditsTimeoutAndClientHeaders() async throws {
+ let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self)
+ defer {
+ if registered {
+ URLProtocol.unregisterClass(OpenRouterStubURLProtocol.self)
+ }
+ OpenRouterStubURLProtocol.handler = nil
+ }
+
+ OpenRouterStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ switch url.path {
+ case "/api/v1/credits":
+ #expect(request.timeoutInterval == 15)
+ #expect(request.value(forHTTPHeaderField: "HTTP-Referer") == "https://codexbar.example")
+ #expect(request.value(forHTTPHeaderField: "X-Title") == "CodexBar QA")
+ let body = #"{"data":{"total_credits":100,"total_usage":40}}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 200)
+ case "/api/v1/key":
+ let body = #"{"data":{"rate_limit":{"requests":120,"interval":"10s"}}}"#
+ return Self.makeResponse(url: url, body: body, statusCode: 200)
+ default:
+ return Self.makeResponse(url: url, body: "{}", statusCode: 404)
+ }
+ }
+
+ let usage = try await OpenRouterUsageFetcher.fetchUsage(
+ apiKey: "sk-or-v1-test",
+ environment: [
+ "OPENROUTER_API_URL": "https://openrouter.test/api/v1",
+ "OPENROUTER_HTTP_REFERER": " https://codexbar.example ",
+ "OPENROUTER_X_TITLE": "CodexBar QA",
+ ])
+
+ #expect(usage.totalCredits == 100)
+ #expect(usage.totalUsage == 40)
+ }
+
+ private static func makeResponse(
+ url: URL,
+ body: String,
+ statusCode: Int = 200) -> (HTTPURLResponse, Data)
+ {
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "application/json"])!
+ return (response, Data(body.utf8))
+ }
+}
+
+final class OpenRouterStubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ request.url?.host == "openrouter.test"
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.handler else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(self.request)
+ self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ self.client?.urlProtocol(self, didLoad: data)
+ self.client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift
index 0e81e34fc..88f1b35c7 100644
--- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift
+++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift
@@ -29,6 +29,29 @@ struct ProviderConfigEnvironmentTests {
#expect(env[key] == "w-token")
}
+ @Test
+ func appliesAPIKeyOverrideForOpenRouter() {
+ let config = ProviderConfig(id: .openrouter, apiKey: "or-token")
+ let env = ProviderConfigEnvironment.applyAPIKeyOverride(
+ base: [:],
+ provider: .openrouter,
+ config: config)
+
+ #expect(env[OpenRouterSettingsReader.envKey] == "or-token")
+ }
+
+ @Test
+ func openRouterConfigOverrideWinsOverEnvironmentToken() {
+ let config = ProviderConfig(id: .openrouter, apiKey: "config-token")
+ let env = ProviderConfigEnvironment.applyAPIKeyOverride(
+ base: [OpenRouterSettingsReader.envKey: "env-token"],
+ provider: .openrouter,
+ config: config)
+
+ #expect(env[OpenRouterSettingsReader.envKey] == "config-token")
+ #expect(ProviderTokenResolver.openRouterToken(environment: env) == "config-token")
+ }
+
@Test
func leavesEnvironmentWhenAPIKeyMissing() {
let config = ProviderConfig(id: .zai, apiKey: nil)
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 6354f65a8..cfeb90048 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -366,6 +366,7 @@ struct SettingsStoreTests {
.ollama,
.synthetic,
.warp,
+ .openrouter,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
index ca97cccab..cb36b24ae 100644
--- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
+++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift
@@ -75,6 +75,46 @@ struct TokenAccountEnvironmentPrecedenceTests {
#expect(ollamaSettings.manualCookieHeader == "session=account-token")
}
+ @Test
+ func applyAccountLabelInAppPreservesSnapshotFields() {
+ let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-apply-app")
+ let store = Self.makeUsageStore(settings: settings)
+ let snapshot = Self.makeSnapshotWithAllFields(provider: .zai)
+ let account = ProviderTokenAccount(
+ id: UUID(),
+ label: "Team Account",
+ token: "account-token",
+ addedAt: 0,
+ lastUsed: nil)
+
+ let labeled = store.applyAccountLabel(snapshot, provider: .zai, account: account)
+
+ Self.expectSnapshotFieldsPreserved(before: snapshot, after: labeled)
+ #expect(labeled.identity?.providerID == .zai)
+ #expect(labeled.identity?.accountEmail == "Team Account")
+ }
+
+ @Test
+ func applyAccountLabelInCLIPreservesSnapshotFields() throws {
+ let context = try TokenAccountCLIContext(
+ selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false),
+ config: CodexBarConfig(providers: []),
+ verbose: false)
+ let snapshot = Self.makeSnapshotWithAllFields(provider: .zai)
+ let account = ProviderTokenAccount(
+ id: UUID(),
+ label: "CLI Account",
+ token: "account-token",
+ addedAt: 0,
+ lastUsed: nil)
+
+ let labeled = context.applyAccountLabel(snapshot, provider: .zai, account: account)
+
+ Self.expectSnapshotFieldsPreserved(before: snapshot, after: labeled)
+ #expect(labeled.identity?.providerID == .zai)
+ #expect(labeled.identity?.accountEmail == "CLI Account")
+ }
+
private static func makeSettingsStore(suite: String) -> SettingsStore {
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
@@ -99,4 +139,85 @@ struct TokenAccountEnvironmentPrecedenceTests {
copilotTokenStore: InMemoryCopilotTokenStore(),
tokenAccountStore: InMemoryTokenAccountStore())
}
+
+ private static func makeUsageStore(settings: SettingsStore) -> UsageStore {
+ UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+ }
+
+ private static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let reset = Date(timeIntervalSince1970: 1_700_003_600)
+ let tokenLimit = ZaiLimitEntry(
+ type: .tokensLimit,
+ unit: .hours,
+ number: 6,
+ usage: 200,
+ currentValue: 40,
+ remaining: 160,
+ percentage: 20,
+ usageDetails: [ZaiUsageDetail(modelCode: "glm-4", usage: 40)],
+ nextResetTime: reset)
+ let identity = ProviderIdentitySnapshot(
+ providerID: provider,
+ accountEmail: nil,
+ accountOrganization: "Org",
+ loginMethod: "Pro")
+
+ return UsageSnapshot(
+ primary: RateWindow(usedPercent: 21, windowMinutes: 60, resetsAt: reset, resetDescription: "primary"),
+ secondary: RateWindow(usedPercent: 42, windowMinutes: 1440, resetsAt: nil, resetDescription: "secondary"),
+ tertiary: RateWindow(usedPercent: 7, windowMinutes: nil, resetsAt: nil, resetDescription: "tertiary"),
+ providerCost: ProviderCostSnapshot(
+ used: 12.5,
+ limit: 25,
+ currencyCode: "USD",
+ period: "Monthly",
+ resetsAt: reset,
+ updatedAt: now),
+ zaiUsage: ZaiUsageSnapshot(
+ tokenLimit: tokenLimit,
+ timeLimit: nil,
+ planName: "Z.ai Pro",
+ updatedAt: now),
+ minimaxUsage: MiniMaxUsageSnapshot(
+ planName: "MiniMax",
+ availablePrompts: 500,
+ currentPrompts: 120,
+ remainingPrompts: 380,
+ windowMinutes: 1440,
+ usedPercent: 24,
+ resetsAt: reset,
+ updatedAt: now),
+ openRouterUsage: OpenRouterUsageSnapshot(
+ totalCredits: 50,
+ totalUsage: 10,
+ balance: 40,
+ usedPercent: 20,
+ rateLimit: nil,
+ updatedAt: now),
+ cursorRequests: CursorRequestUsage(used: 7, limit: 70),
+ updatedAt: now,
+ identity: identity)
+ }
+
+ private static func expectSnapshotFieldsPreserved(before: UsageSnapshot, after: UsageSnapshot) {
+ #expect(after.primary?.usedPercent == before.primary?.usedPercent)
+ #expect(after.secondary?.usedPercent == before.secondary?.usedPercent)
+ #expect(after.tertiary?.usedPercent == before.tertiary?.usedPercent)
+ #expect(after.providerCost?.used == before.providerCost?.used)
+ #expect(after.providerCost?.limit == before.providerCost?.limit)
+ #expect(after.providerCost?.currencyCode == before.providerCost?.currencyCode)
+ #expect(after.zaiUsage?.planName == before.zaiUsage?.planName)
+ #expect(after.zaiUsage?.tokenLimit?.usage == before.zaiUsage?.tokenLimit?.usage)
+ #expect(after.minimaxUsage?.planName == before.minimaxUsage?.planName)
+ #expect(after.minimaxUsage?.availablePrompts == before.minimaxUsage?.availablePrompts)
+ #expect(after.openRouterUsage?.balance == before.openRouterUsage?.balance)
+ #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests)
+ #expect(after.cursorRequests?.used == before.cursorRequests?.used)
+ #expect(after.cursorRequests?.limit == before.cursorRequests?.limit)
+ #expect(after.updatedAt == before.updatedAt)
+ }
}
diff --git a/docs/openrouter.md b/docs/openrouter.md
new file mode 100644
index 000000000..a0d7985e3
--- /dev/null
+++ b/docs/openrouter.md
@@ -0,0 +1,56 @@
+# 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`) |
+| `OPENROUTER_HTTP_REFERER` | Optional client referer sent as `HTTP-Referer` header |
+| `OPENROUTER_X_TITLE` | Optional client title sent as `X-Title` header (defaults to `CodexBar`) |
+
+## 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 b69d05694..5b6126847 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, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -36,6 +36,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Amp | Web settings page via browser cookies (`web`). |
| Warp | API token (config/env) → GraphQL request limits (`api`). |
| Ollama | Web settings page via browser cookies (`web`). |
+| OpenRouter | API token (config, overrides env) → credits API (`api`). |
## Codex
- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -154,4 +155,12 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: none yet.
- Details: `docs/ollama.md`.
+## OpenRouter
+- API token from `~/.codexbar/config.json` (`providerConfig.openrouter.apiKey`) 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.