From 75d528dc3bc742de6db867ac5cddcc58a407f168 Mon Sep 17 00:00:00 2001 From: chountalas Date: Tue, 3 Feb 2026 20:51:10 -0700 Subject: [PATCH 1/2] feat: Add OpenRouter provider for credit-based usage tracking Adds OpenRouter as a new provider that tracks credit usage via their API. Features: - Fetches credits from /api/v1/credits endpoint - Shows credit usage percentage and remaining balance - Supports OPENROUTER_API_KEY environment variable - Settings UI for API key configuration New files: - OpenRouterProviderDescriptor.swift - Provider descriptor + fetch strategy - OpenRouterUsageStats.swift - API fetcher + response models - OpenRouterSettingsReader.swift - Environment variable reader - OpenRouterProviderImplementation.swift - UI implementation - OpenRouterSettingsStore.swift - Settings extension - ProviderIcon-openrouter.svg - Provider icon - docs/openrouter.md - Provider documentation Co-Authored-By: Claude Opus 4.5 --- README.md | 3 +- .../OpenRouterProviderImplementation.swift | 56 +++++ .../OpenRouter/OpenRouterSettingsStore.swift | 16 ++ .../Resources/ProviderIcon-openrouter.svg | 9 + .../CodexBarCore/Logging/LogCategories.swift | 1 + .../OpenRouterProviderDescriptor.swift | 83 +++++++ .../OpenRouter/OpenRouterSettingsReader.swift | 38 +++ .../OpenRouter/OpenRouterUsageStats.swift | 233 ++++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderTokenResolver.swift | 10 + .../CodexBarCore/Providers/Providers.swift | 2 + Sources/CodexBarCore/UsageFetcher.swift | 5 + docs/claude.md | 9 + docs/openrouter.md | 54 ++++ docs/providers.md | 12 +- 15 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-openrouter.svg create mode 100644 Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift create mode 100644 docs/openrouter.md diff --git a/README.md b/README.md index 757fbf03..c68d64cf 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. CodexBar menu screenshot @@ -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 00000000..89e47e1e --- /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 00000000..5f0ee030 --- /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/Resources/ProviderIcon-openrouter.svg b/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg new file mode 100644 index 00000000..c5fb0c13 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-openrouter.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index d3d31c8f..3c00af6b 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 00000000..9334241f --- /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 00000000..646b648f --- /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 00000000..2aa4e680 --- /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 6aff8369..a0f84e75 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 6b978775..78256dc8 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 a267fb95..7f4fd17e 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 ca300ea9..23b1ccce 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/docs/claude.md b/docs/claude.md index 564672dd..f1cfa7ba 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 00000000..dea631de --- /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 d25dfb88..3546cc2b 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. From c06add9571fc00cac061afb1f2ecf7447164e6a5 Mon Sep 17 00:00:00 2001 From: chountalas Date: Tue, 3 Feb 2026 20:56:36 -0700 Subject: [PATCH 2/2] fix: Add missing openrouter switch cases for exhaustive matching Added .openrouter case to all switch statements that iterate over UsageProvider to fix compilation errors: - CostUsageScanner.swift (returns empty report) - TokenAccountCLI.swift (returns nil settings) - CodexBarWidgetProvider.swift (not yet supported in widgets) - CodexBarWidgetViews.swift (short label + color) - ProviderImplementationRegistry.swift (implementation registration) - UsageStore.swift (debug log output) Co-Authored-By: Claude Opus 4.5 --- .../Providers/Shared/ProviderImplementationRegistry.swift | 1 + Sources/CodexBar/UsageStore.swift | 7 +++++++ Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift | 2 ++ Sources/CodexBarWidget/CodexBarWidgetProvider.swift | 1 + Sources/CodexBarWidget/CodexBarWidgetViews.swift | 3 +++ 6 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3..ebe7db03 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/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 69491056..65da99ca 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 a0a88371..67071d64 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/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index d47d7d55..a2b36e84 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 1634611e..b3a46180 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 ed39b450..14948622 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 } } }