From 8348c85cd8d43affa0c9d83be20ff42d895fe1dc Mon Sep 17 00:00:00 2001 From: chountalas Date: Tue, 3 Feb 2026 20:51:10 -0700 Subject: [PATCH 01/11] 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 | 11 +- 15 files changed, 529 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 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..89e47e1ea --- /dev/null +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift @@ -0,0 +1,56 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct OpenRouterProviderImplementation: ProviderImplementation { + let id: UsageProvider = .openrouter + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.openRouterAPIToken + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return nil + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if OpenRouterSettingsReader.apiToken(environment: context.environment) != nil { + return true + } + context.settings.ensureOpenRouterAPITokenLoaded() + return !context.settings.openRouterAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "openrouter-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys.", + kind: .secure, + placeholder: "sk-or-v1-...", + binding: context.stringBinding(\.openRouterAPIToken), + actions: [], + isVisible: nil, + onActivate: { context.settings.ensureOpenRouterAPITokenLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift new file mode 100644 index 000000000..5f0ee030f --- /dev/null +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift @@ -0,0 +1,16 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var openRouterAPIToken: String { + get { self.configSnapshot.providerConfig(for: .openrouter)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .openrouter) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .openrouter, field: "apiKey", value: newValue) + } + } + + func ensureOpenRouterAPITokenLoaded() {} +} diff --git a/Sources/CodexBar/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/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..646b648f9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Reads OpenRouter settings from environment variables +public enum OpenRouterSettingsReader { + /// Environment variable key for OpenRouter API token + public static let envKey = "OPENROUTER_API_KEY" + + /// Returns the API token from environment if present and non-empty + public static func apiToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + cleaned(environment[envKey]) + } + + /// Returns the API URL, defaulting to production endpoint + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment["OPENROUTER_API_URL"], + let url = URL(string: cleaned(override) ?? "") + { + return url + } + return URL(string: "https://openrouter.ai/api/v1")! + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift new file mode 100644 index 000000000..2aa4e680e --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -0,0 +1,233 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// OpenRouter credits API response +public struct OpenRouterCreditsResponse: Decodable, Sendable { + public let data: OpenRouterCreditsData +} + +/// OpenRouter credits data +public struct OpenRouterCreditsData: Decodable, Sendable { + /// Total credits ever added to the account (in USD) + public let totalCredits: Double + /// Total credits used (in USD) + public let totalUsage: Double + + private enum CodingKeys: String, CodingKey { + case totalCredits = "total_credits" + case totalUsage = "total_usage" + } + + /// Remaining credits (total - usage) + public var balance: Double { + max(0, totalCredits - totalUsage) + } + + /// Usage percentage (0-100) + public var usedPercent: Double { + guard totalCredits > 0 else { return 0 } + return min(100, (totalUsage / totalCredits) * 100) + } +} + +/// OpenRouter key info API response (for rate limits) +public struct OpenRouterKeyResponse: Decodable, Sendable { + public let data: OpenRouterKeyData +} + +/// OpenRouter key data with rate limit info +public struct OpenRouterKeyData: Decodable, Sendable { + /// Rate limit per interval + public let rateLimit: OpenRouterRateLimit? + /// Usage limits + public let limit: Double? + /// Current usage + public let usage: Double? + + private enum CodingKeys: String, CodingKey { + case rateLimit = "rate_limit" + case limit + case usage + } +} + +/// OpenRouter rate limit info +public struct OpenRouterRateLimit: Decodable, Sendable { + /// Number of requests allowed + public let requests: Int + /// Interval for the rate limit (e.g., "10s", "1m") + public let interval: String +} + +/// Complete OpenRouter usage snapshot +public struct OpenRouterUsageSnapshot: Sendable { + public let totalCredits: Double + public let totalUsage: Double + public let balance: Double + public let usedPercent: Double + public let rateLimit: OpenRouterRateLimit? + public let updatedAt: Date + + public init( + totalCredits: Double, + totalUsage: Double, + balance: Double, + usedPercent: Double, + rateLimit: OpenRouterRateLimit?, + updatedAt: Date) + { + self.totalCredits = totalCredits + self.totalUsage = totalUsage + self.balance = balance + self.usedPercent = usedPercent + self.rateLimit = rateLimit + self.updatedAt = updatedAt + } + + /// Returns true if this snapshot contains valid data + public var isValid: Bool { + totalCredits >= 0 + } +} + +extension OpenRouterUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + // Primary: credits usage percentage + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Credits") + + // Format balance for identity display + let balanceStr = String(format: "$%.2f", balance) + let identity = ProviderIdentitySnapshot( + providerID: .openrouter, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: \(balanceStr)") + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + openRouterUsage: self, + updatedAt: updatedAt, + identity: identity) + } +} + +/// Fetches usage stats from the OpenRouter API +public struct OpenRouterUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.openRouterUsage) + + /// Fetches credits usage from OpenRouter using the provided API key + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> OpenRouterUsageSnapshot + { + guard !apiKey.isEmpty else { + throw OpenRouterUsageError.invalidCredentials + } + + let baseURL = OpenRouterSettingsReader.apiURL(environment: environment) + let creditsURL = baseURL.appendingPathComponent("credits") + + var request = URLRequest(url: creditsURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OpenRouterUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorMessage)") + throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") + } + + // Log raw response for debugging + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("OpenRouter credits response: \(jsonString)") + } + + do { + let decoder = JSONDecoder() + let creditsResponse = try decoder.decode(OpenRouterCreditsResponse.self, from: data) + + // Optionally fetch rate limit info from /key endpoint + let rateLimit = await fetchRateLimit(apiKey: apiKey, baseURL: baseURL) + + return OpenRouterUsageSnapshot( + totalCredits: creditsResponse.data.totalCredits, + totalUsage: creditsResponse.data.totalUsage, + balance: creditsResponse.data.balance, + usedPercent: creditsResponse.data.usedPercent, + rateLimit: rateLimit, + updatedAt: Date()) + } catch let error as DecodingError { + Self.log.error("OpenRouter JSON decoding error: \(error.localizedDescription)") + throw OpenRouterUsageError.parseFailed(error.localizedDescription) + } catch let error as OpenRouterUsageError { + throw error + } catch { + Self.log.error("OpenRouter parsing error: \(error.localizedDescription)") + throw OpenRouterUsageError.parseFailed(error.localizedDescription) + } + } + + /// Fetches rate limit info from /key endpoint + private static func fetchRateLimit(apiKey: String, baseURL: URL) async -> OpenRouterRateLimit? { + let keyURL = baseURL.appendingPathComponent("key") + + var request = URLRequest(url: keyURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + return nil + } + + let decoder = JSONDecoder() + let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: data) + return keyResponse.data.rateLimit + } catch { + Self.log.debug("Failed to fetch OpenRouter rate limit: \(error.localizedDescription)") + return nil + } + } +} + +/// Errors that can occur during OpenRouter usage fetching +public enum OpenRouterUsageError: LocalizedError, Sendable { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "Invalid OpenRouter API credentials" + case let .networkError(message): + "OpenRouter network error: \(message)" + case let .apiError(message): + "OpenRouter API error: \(message)" + case let .parseFailed(message): + "Failed to parse OpenRouter response: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 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..d7a0b7d7c 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -22,6 +22,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case amp case ollama case synthetic + case openrouter case warp } @@ -47,6 +48,7 @@ public enum IconStyle: Sendable, CaseIterable { case amp case ollama case synthetic + case openrouter case warp case combined } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index ca300ea9f..23b1ccce2 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -54,6 +54,7 @@ public struct UsageSnapshot: Codable, Sendable { public let providerCost: ProviderCostSnapshot? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? + public let openRouterUsage: OpenRouterUsageSnapshot? public let cursorRequests: CursorRequestUsage? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -77,6 +78,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, + openRouterUsage: OpenRouterUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) @@ -87,6 +89,7 @@ public struct UsageSnapshot: Codable, Sendable { self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage + self.openRouterUsage = openRouterUsage self.cursorRequests = cursorRequests self.updatedAt = updatedAt self.identity = identity @@ -100,6 +103,7 @@ public struct UsageSnapshot: Codable, Sendable { self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time + self.openRouterUsage = nil // Not persisted, fetched fresh each time self.cursorRequests = nil // Not persisted, fetched fresh each time self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { @@ -183,6 +187,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, + openRouterUsage: self.openRouterUsage, cursorRequests: self.cursorRequests, updatedAt: self.updatedAt, identity: scopedIdentity) diff --git a/docs/claude.md b/docs/claude.md index 22737efd9..50cb14bef 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -113,3 +113,12 @@ Usage source picker: `Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift` - Cost usage: `Sources/CodexBarCore/CostUsageFetcher.swift`, `Sources/CodexBarCore/Vendored/CostUsage/*` + + + +# Recent Activity + + + +*No recent activity* + \ No newline at end of file diff --git a/docs/openrouter.md b/docs/openrouter.md new file mode 100644 index 000000000..dea631dec --- /dev/null +++ b/docs/openrouter.md @@ -0,0 +1,54 @@ +# OpenRouter Provider + +[OpenRouter](https://openrouter.ai) is a unified API that provides access to multiple AI models from different providers (OpenAI, Anthropic, Google, Meta, and more) through a single endpoint. + +## Authentication + +OpenRouter uses API key authentication. Get your API key from [OpenRouter Settings](https://openrouter.ai/settings/keys). + +### Environment Variable + +Set the `OPENROUTER_API_KEY` environment variable: + +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +``` + +### Settings + +You can also configure the API key in CodexBar Settings → Providers → OpenRouter. + +## Data Source + +The OpenRouter provider fetches usage data from two API endpoints: + +1. **Credits API** (`/api/v1/credits`): Returns total credits purchased and total usage. The balance is calculated as `total_credits - total_usage`. + +2. **Key API** (`/api/v1/key`): Returns rate limit information for your API key. + +## Display + +The OpenRouter menu card shows: + +- **Primary meter**: Credit usage percentage (how much of your purchased credits have been used) +- **Balance**: Displayed in the identity section as "Balance: $X.XX" + +## CLI Usage + +```bash +codexbar --provider openrouter +codexbar -p or # alias +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `OPENROUTER_API_KEY` | Your OpenRouter API key (required) | +| `OPENROUTER_API_URL` | Override the base API URL (optional, defaults to `https://openrouter.ai/api/v1`) | + +## Notes + +- Credit values are cached on OpenRouter's side and may be up to 60 seconds stale +- OpenRouter uses a credit-based billing system where you pre-purchase credits +- Rate limits depend on your credit balance (10+ credits = 1000 free model requests/day) diff --git a/docs/providers.md b/docs/providers.md index b69d05694..a776fc562 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 (Keychain/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 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 e61b73c71815e047ada71920c9edbe9b550e7f36 Mon Sep 17 00:00:00 2001 From: chountalas Date: Tue, 3 Feb 2026 20:56:36 -0700 Subject: [PATCH 02/11] 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 | 5 +++++ Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift | 2 +- Sources/CodexBarWidget/CodexBarWidgetProvider.swift | 1 + Sources/CodexBarWidget/CodexBarWidgetViews.swift | 3 +++ 6 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 8754e595f..9fbf84b39 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -31,6 +31,7 @@ enum ProviderImplementationRegistry { case .amp: AmpProviderImplementation() case .ollama: OllamaProviderImplementation() case .synthetic: SyntheticProviderImplementation() + case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 84280b3c0..aa5faddb4 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1210,6 +1210,11 @@ extension UsageStore { text = await self.debugOllamaLog( ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) + case .openrouter: + let resolution = ProviderTokenResolver.openRouterResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + 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..4d6d05d17 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 } } 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..aa9739c01 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -60,6 +60,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 } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 6e3ea3528..f007623f6 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" } } @@ -609,6 +610,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) } From 07927c461cb84b55eea34adda96fd62c1ae126da Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 00:27:10 +0530 Subject: [PATCH 03/11] Fix OpenRouter formatting and lint guards --- .../Shared/ProviderImplementationRegistry.swift | 1 + .../OpenRouter/OpenRouterSettingsReader.swift | 2 +- .../Providers/OpenRouter/OpenRouterUsageStats.swift | 10 +++++----- Sources/CodexBarWidget/CodexBarWidgetProvider.swift | 1 + Sources/CodexBarWidget/CodexBarWidgetViews.swift | 1 + 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 9fbf84b39..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() diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift index 646b648f9..e5e3f4d78 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift @@ -7,7 +7,7 @@ public enum OpenRouterSettingsReader { /// 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]) + self.cleaned(environment[self.envKey]) } /// Returns the API URL, defaulting to production endpoint diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index 2aa4e680e..bc8286545 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -22,13 +22,13 @@ public struct OpenRouterCreditsData: Decodable, Sendable { /// Remaining credits (total - usage) public var balance: Double { - max(0, totalCredits - totalUsage) + max(0, self.totalCredits - self.totalUsage) } /// Usage percentage (0-100) public var usedPercent: Double { - guard totalCredits > 0 else { return 0 } - return min(100, (totalUsage / totalCredits) * 100) + guard self.totalCredits > 0 else { return 0 } + return min(100, (self.totalUsage / self.totalCredits) * 100) } } @@ -88,7 +88,7 @@ public struct OpenRouterUsageSnapshot: Sendable { /// Returns true if this snapshot contains valid data public var isValid: Bool { - totalCredits >= 0 + self.totalCredits >= 0 } } @@ -115,7 +115,7 @@ extension OpenRouterUsageSnapshot { tertiary: nil, providerCost: nil, openRouterUsage: self, - updatedAt: updatedAt, + updatedAt: self.updatedAt, identity: identity) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index aa9739c01..1f8a8cdfe 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 diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index f007623f6..7ad1064e5 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -570,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: From a8752ee3dd9a51da5c6d4260170a31b26e97a123 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 00:30:42 +0530 Subject: [PATCH 04/11] Wire OpenRouter config token into fetch env --- .../Config/ProviderConfigEnvironment.swift | 2 ++ .../ProviderConfigEnvironmentTests.swift | 11 +++++++++++ 2 files changed, 13 insertions(+) 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/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 0e81e34fc..04ee4b25e 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -29,6 +29,17 @@ 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 leavesEnvironmentWhenAPIKeyMissing() { let config = ProviderConfig(id: .zai, apiKey: nil) From c1b53df819fe4210762df0533207d277169a3ab7 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 00:54:54 +0530 Subject: [PATCH 05/11] Preserve provider order and bound OpenRouter key fetch --- .../OpenRouter/OpenRouterUsageStats.swift | 46 +++++++++++++++++-- .../CodexBarCore/Providers/Providers.swift | 4 +- Tests/CodexBarTests/SettingsStoreTests.swift | 1 + 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index bc8286545..5e0d6595e 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -123,6 +123,7 @@ extension OpenRouterUsageSnapshot { /// 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 /// Fetches credits usage from OpenRouter using the provided API key public static func fetchUsage( @@ -162,8 +163,12 @@ public struct OpenRouterUsageFetcher: Sendable { 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) + // 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, @@ -184,13 +189,48 @@ public struct OpenRouterUsageFetcher: Sendable { } /// Fetches rate limit info from /key endpoint - private static func fetchRateLimit(apiKey: String, baseURL: URL) async -> OpenRouterRateLimit? { + 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 { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + 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) diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index d7a0b7d7c..b6d75ebb1 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -22,8 +22,8 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case amp case ollama case synthetic - case openrouter case warp + case openrouter } // swiftformat:enable sortDeclarations @@ -48,8 +48,8 @@ public enum IconStyle: Sendable, CaseIterable { case amp case ollama case synthetic - case openrouter case warp + case openrouter case combined } 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. From 67de5f5061435d69711f591e0337dd3ebee4b472 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 01:07:35 +0530 Subject: [PATCH 06/11] Fix OpenRouter reset text and timeout logging --- .../OpenRouter/OpenRouterUsageStats.swift | 10 +++++++-- .../OpenRouterUsageStatsTests.swift | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 Tests/CodexBarTests/OpenRouterUsageStatsTests.swift diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index 5e0d6595e..fe31cbeb1 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -99,7 +99,7 @@ extension OpenRouterUsageSnapshot { usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, - resetDescription: "Credits") + resetDescription: nil) // Format balance for identity display let balanceStr = String(format: "$%.2f", balance) @@ -205,7 +205,13 @@ public struct OpenRouterUsageFetcher: Sendable { timeoutSeconds: timeout) } group.addTask { - try? await Task.sleep(nanoseconds: timeoutNanoseconds) + 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 } diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift new file mode 100644 index 000000000..03c569957 --- /dev/null +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -0,0 +1,22 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +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) + } +} From ff1c5848263b2dffb8690bde0db473ae5da2f14a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 01:43:18 +0530 Subject: [PATCH 07/11] Harden OpenRouter logging and widget selection --- Sources/CodexBar/UsageStore.swift | 6 ++- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 + .../OpenRouter/OpenRouterUsageStats.swift | 38 +++++++++++++++---- .../CodexBarWidgetProvider.swift | 3 +- docs/claude.md | 9 ----- 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index aa5faddb4..5536c0c0b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1154,6 +1154,10 @@ extension UsageStore { let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader + let openRouterEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: ProcessInfo.processInfo.environment, + 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", @@ -1211,7 +1215,7 @@ extension UsageStore { ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) case .openrouter: - let resolution = ProviderTokenResolver.openRouterResolution() + let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" text = "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 4d6d05d17..14b2e0af9 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -228,6 +228,8 @@ struct TokenAccountCLIContext { tertiary: snapshot.tertiary, providerCost: snapshot.providerCost, zaiUsage: snapshot.zaiUsage, + minimaxUsage: snapshot.minimaxUsage, + openRouterUsage: snapshot.openRouterUsage, cursorRequests: snapshot.cursorRequests, updatedAt: snapshot.updatedAt, identity: identity) diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index fe31cbeb1..75dae006f 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -124,6 +124,8 @@ extension OpenRouterUsageSnapshot { public struct OpenRouterUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.openRouterUsage) private static let rateLimitTimeoutSeconds: TimeInterval = 1.0 + private static let maxErrorBodyLength = 240 + private static let debugFullErrorBodiesEnvKey = "CODEXBAR_DEBUG_OPENROUTER_ERROR_BODIES" /// Fetches credits usage from OpenRouter using the provided API key public static func fetchUsage( @@ -149,14 +151,12 @@ public struct OpenRouterUsageFetcher: Sendable { } 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)") + let errorSummary = Self.sanitizedResponseBodySummary(data) + if Self.debugFullErrorBodiesEnabled, let fullBody = String(data: data, encoding: .utf8), !fullBody.isEmpty { + Self.log.debug("OpenRouter non-200 body: \(fullBody)") + } + Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorSummary)") + throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorSummary)") } do { @@ -255,6 +255,28 @@ public struct OpenRouterUsageFetcher: Sendable { return nil } } + + private static var debugFullErrorBodiesEnabled: Bool { + ProcessInfo.processInfo.environment[self.debugFullErrorBodiesEnvKey] == "1" + } + + 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 = 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[.. [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/docs/claude.md b/docs/claude.md index 50cb14bef..22737efd9 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -113,12 +113,3 @@ 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 From 6e007a4ff3f682dd7b964bc13b0c890640c85809 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 02:09:19 +0530 Subject: [PATCH 08/11] Harden OpenRouter diagnostics and token account labels --- .../OpenRouterProviderImplementation.swift | 3 +- .../OpenRouter/OpenRouterSettingsStore.swift | 2 - .../CodexBar/UsageStore+TokenAccounts.swift | 2 + Sources/CodexBar/UsageStore.swift | 17 +++++++- .../OpenRouter/OpenRouterUsageStats.swift | 40 +++++++++++++++++-- docs/providers.md | 4 +- 6 files changed, 57 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift index 89e47e1ea..b604dcf6c 100644 --- a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift @@ -29,7 +29,6 @@ struct OpenRouterProviderImplementation: ProviderImplementation { if OpenRouterSettingsReader.apiToken(environment: context.environment) != nil { return true } - context.settings.ensureOpenRouterAPITokenLoaded() return !context.settings.openRouterAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -50,7 +49,7 @@ struct OpenRouterProviderImplementation: ProviderImplementation { binding: context.stringBinding(\.openRouterAPIToken), actions: [], isVisible: nil, - onActivate: { context.settings.ensureOpenRouterAPITokenLoaded() }), + onActivate: nil), ] } } diff --git a/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift index 5f0ee030f..130cdf3dd 100644 --- a/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterSettingsStore.swift @@ -11,6 +11,4 @@ extension SettingsStore { self.logSecretUpdate(provider: .openrouter, field: "apiKey", value: newValue) } } - - func ensureOpenRouterAPITokenLoaded() {} } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 7127a1233..3c6cae7d1 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -198,6 +198,8 @@ extension UsageStore { tertiary: snapshot.tertiary, providerCost: snapshot.providerCost, zaiUsage: snapshot.zaiUsage, + minimaxUsage: snapshot.minimaxUsage, + openRouterUsage: snapshot.openRouterUsage, cursorRequests: snapshot.cursorRequests, updatedAt: snapshot.updatedAt, identity: identity) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5536c0c0b..20bb7491f 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1154,8 +1154,13 @@ 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: ProcessInfo.processInfo.environment, + base: processEnvironment, provider: .openrouter, config: self.settings.providerConfig(for: .openrouter)) return await Task.detached(priority: .utility) { () -> String in @@ -1217,7 +1222,15 @@ extension UsageStore { case .openrouter: let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" + 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() diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index 75dae006f..f7a1ea040 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -125,6 +125,7 @@ public struct OpenRouterUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.openRouterUsage) private static let rateLimitTimeoutSeconds: TimeInterval = 1.0 private static let maxErrorBodyLength = 240 + private static let maxDebugErrorBodyLength = 2000 private static let debugFullErrorBodiesEnvKey = "CODEXBAR_DEBUG_OPENROUTER_ERROR_BODIES" /// Fetches credits usage from OpenRouter using the provided API key @@ -152,8 +153,8 @@ public struct OpenRouterUsageFetcher: Sendable { guard httpResponse.statusCode == 200 else { let errorSummary = Self.sanitizedResponseBodySummary(data) - if Self.debugFullErrorBodiesEnabled, let fullBody = String(data: data, encoding: .utf8), !fullBody.isEmpty { - Self.log.debug("OpenRouter non-200 body: \(fullBody)") + if Self.debugFullErrorBodiesEnabled, let debugBody = Self.redactedDebugResponseBody(data) { + Self.log.debug("OpenRouter non-200 body (redacted): \(debugBody)") } Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorSummary)") throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorSummary)") @@ -267,7 +268,7 @@ public struct OpenRouterUsageFetcher: Sendable { return "non-text body (\(data.count) bytes)" } - let body = rawBody + let body = Self.redactSensitiveBodyContent(rawBody) .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -277,6 +278,39 @@ public struct OpenRouterUsageFetcher: Sendable { 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) + } + } } /// Errors that can occur during OpenRouter usage fetching diff --git a/docs/providers.md b/docs/providers.md index a776fc562..cc456ebdc 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -36,7 +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 (Keychain/env) → credits API (`api`). | +| OpenRouter | API token (config/env override) → credits API (`api`). | ## Codex - Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -156,7 +156,7 @@ until the session is invalid, to avoid repeated Keychain prompts. - Details: `docs/ollama.md`. ## OpenRouter -- API token from Keychain or `OPENROUTER_API_KEY` env var. +- 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. From d2aad5a74ae8cfc44fa00bb2a6443d53d6afa540 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 02:36:51 +0530 Subject: [PATCH 09/11] Harden OpenRouter errors and relabel snapshot copies --- .../CodexBar/UsageStore+TokenAccounts.swift | 12 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 12 +- .../OpenRouter/OpenRouterUsageStats.swift | 14 +- Sources/CodexBarCore/UsageFetcher.swift | 16 ++- .../OpenRouterUsageStatsTests.swift | 98 +++++++++++++- .../ProviderConfigEnvironmentTests.swift | 12 ++ ...kenAccountEnvironmentPrecedenceTests.swift | 121 ++++++++++++++++++ docs/providers.md | 2 +- 8 files changed, 253 insertions(+), 34 deletions(-) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 3c6cae7d1..3e55ffa9f 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -192,16 +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, - minimaxUsage: snapshot.minimaxUsage, - openRouterUsage: snapshot.openRouterUsage, - cursorRequests: snapshot.cursorRequests, - updatedAt: snapshot.updatedAt, - identity: identity) + return snapshot.withIdentity(identity) } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 14b2e0af9..c1617905e 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -222,17 +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, - minimaxUsage: snapshot.minimaxUsage, - openRouterUsage: snapshot.openRouterUsage, - cursorRequests: snapshot.cursorRequests, - updatedAt: snapshot.updatedAt, - identity: identity) + return snapshot.withIdentity(identity) } func effectiveSourceMode( diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index f7a1ea040..fc0199119 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -152,12 +152,12 @@ public struct OpenRouterUsageFetcher: Sendable { } guard httpResponse.statusCode == 200 else { - let errorSummary = Self.sanitizedResponseBodySummary(data) + let errorSummary = LogRedactor.redact(Self.sanitizedResponseBodySummary(data)) if Self.debugFullErrorBodiesEnabled, let debugBody = Self.redactedDebugResponseBody(data) { - Self.log.debug("OpenRouter non-200 body (redacted): \(debugBody)") + 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): \(errorSummary)") + throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode)") } do { @@ -311,6 +311,14 @@ public struct OpenRouterUsageFetcher: Sendable { options: .regularExpression) } } + + static func _sanitizedResponseBodySummaryForTesting(_ body: String) -> String { + self.sanitizedResponseBodySummary(Data(body.utf8)) + } + + static func _redactedDebugResponseBodyForTesting(_ body: String) -> String? { + self.redactedDebugResponseBody(Data(body.utf8)) + } } /// Errors that can occur during OpenRouter usage fetching diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 23b1ccce2..1a0fef004 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -176,11 +176,8 @@ 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( + public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { + UsageSnapshot( primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, @@ -190,7 +187,14 @@ public struct UsageSnapshot: Codable, Sendable { 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/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index 03c569957..4e81dba71 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -1,8 +1,8 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore -@Suite +@Suite(.serialized) struct OpenRouterUsageStatsTests { @Test func toUsageSnapshot_doesNotSetSyntheticResetDescription() { @@ -19,4 +19,98 @@ struct OpenRouterUsageStatsTests { #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")) + } + } + + 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 04ee4b25e..88f1b35c7 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -40,6 +40,18 @@ struct ProviderConfigEnvironmentTests { #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/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/providers.md b/docs/providers.md index cc456ebdc..5b6126847 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -36,7 +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/env override) → credits API (`api`). | +| OpenRouter | API token (config, overrides env) → credits API (`api`). | ## Codex - Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. From 85473358ad1385e12aa83147a85b6e97b6e01b4a Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 12:25:26 +0530 Subject: [PATCH 10/11] Gate OpenRouter test hooks and document snapshot copy --- .../Providers/OpenRouter/OpenRouterUsageStats.swift | 2 ++ Sources/CodexBarCore/UsageFetcher.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index fc0199119..7a138eedc 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -312,6 +312,7 @@ public struct OpenRouterUsageFetcher: Sendable { } } + #if DEBUG static func _sanitizedResponseBodySummaryForTesting(_ body: String) -> String { self.sanitizedResponseBodySummary(Data(body.utf8)) } @@ -319,6 +320,7 @@ public struct OpenRouterUsageFetcher: Sendable { static func _redactedDebugResponseBodyForTesting(_ body: String) -> String? { self.redactedDebugResponseBody(Data(body.utf8)) } + #endif } /// Errors that can occur during OpenRouter usage fetching diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 1a0fef004..9834b00bf 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -176,6 +176,7 @@ public struct UsageSnapshot: Codable, Sendable { self.identity(for: provider)?.loginMethod } + /// 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, From 9d6b32b8789fed62a6c1162bcd1a2276f0c9d8c4 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 18 Feb 2026 12:59:34 +0530 Subject: [PATCH 11/11] Improve OpenRouter request resilience and headers --- .../OpenRouter/OpenRouterUsageStats.swift | 22 +++++++++-- .../OpenRouterUsageStatsTests.swift | 39 +++++++++++++++++++ docs/openrouter.md | 2 + 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index 7a138eedc..15712b360 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -124,9 +124,13 @@ extension OpenRouterUsageSnapshot { 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( @@ -144,6 +148,12 @@ public struct OpenRouterUsageFetcher: Sendable { 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) @@ -153,7 +163,9 @@ public struct OpenRouterUsageFetcher: Sendable { guard httpResponse.statusCode == 200 else { let errorSummary = LogRedactor.redact(Self.sanitizedResponseBodySummary(data)) - if Self.debugFullErrorBodiesEnabled, let debugBody = Self.redactedDebugResponseBody(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)") @@ -257,8 +269,12 @@ public struct OpenRouterUsageFetcher: Sendable { } } - private static var debugFullErrorBodiesEnabled: Bool { - ProcessInfo.processInfo.environment[self.debugFullErrorBodiesEnvKey] == "1" + 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 { diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index 4e81dba71..8ce66e69e 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -72,6 +72,45 @@ struct OpenRouterUsageStatsTests { } } + @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, diff --git a/docs/openrouter.md b/docs/openrouter.md index dea631dec..a0d7985e3 100644 --- a/docs/openrouter.md +++ b/docs/openrouter.md @@ -46,6 +46,8 @@ codexbar -p or # alias |----------|-------------| | `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