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. 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 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.