Files
steipete--codexbar/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift
T
2026-07-13 12:22:33 +08:00

466 lines
17 KiB
Swift

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
public struct OpenRouterKeyResponse: Decodable, Sendable {
public let data: OpenRouterKeyData
}
/// OpenRouter key data with quota and 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?
/// API key usage for the current UTC day.
public let usageDaily: Double?
/// API key usage for the current UTC week.
public let usageWeekly: Double?
/// API key usage for the current UTC month.
public let usageMonthly: Double?
private enum CodingKeys: String, CodingKey {
case rateLimit = "rate_limit"
case limit
case usage
case usageDaily = "usage_daily"
case usageWeekly = "usage_weekly"
case usageMonthly = "usage_monthly"
}
}
/// OpenRouter rate limit info
public struct OpenRouterRateLimit: Codable, Sendable {
/// Number of requests allowed
public let requests: Int
/// Interval for the rate limit (e.g., "10s", "1m")
public let interval: String
public init(requests: Int, interval: String) {
self.requests = requests
self.interval = interval
}
}
public enum OpenRouterKeyQuotaStatus: String, Codable, Sendable {
case available
case noLimitConfigured
case unavailable
}
/// Complete OpenRouter usage snapshot
public struct OpenRouterUsageSnapshot: Codable, Sendable {
public let totalCredits: Double
public let totalUsage: Double
public let balance: Double
public let usedPercent: Double
public let keyDataFetched: Bool
public let keyLimit: Double?
public let keyUsage: Double?
public let keyUsageDaily: Double?
public let keyUsageWeekly: Double?
public let keyUsageMonthly: Double?
public let rateLimit: OpenRouterRateLimit?
public let updatedAt: Date
public init(
totalCredits: Double,
totalUsage: Double,
balance: Double,
usedPercent: Double,
keyDataFetched: Bool = false,
keyLimit: Double? = nil,
keyUsage: Double? = nil,
keyUsageDaily: Double? = nil,
keyUsageWeekly: Double? = nil,
keyUsageMonthly: Double? = nil,
rateLimit: OpenRouterRateLimit?,
updatedAt: Date)
{
self.totalCredits = totalCredits
self.totalUsage = totalUsage
self.balance = balance
self.usedPercent = usedPercent
self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil ||
keyUsageDaily != nil || keyUsageWeekly != nil || keyUsageMonthly != nil
self.keyLimit = keyLimit
self.keyUsage = keyUsage
self.keyUsageDaily = keyUsageDaily
self.keyUsageWeekly = keyUsageWeekly
self.keyUsageMonthly = keyUsageMonthly
self.rateLimit = rateLimit
self.updatedAt = updatedAt
}
/// Returns true if this snapshot contains valid data
public var isValid: Bool {
self.totalCredits >= 0
}
public var hasValidKeyQuota: Bool {
guard self.keyDataFetched,
let keyLimit,
let keyUsage
else {
return false
}
return keyLimit > 0 && keyUsage >= 0
}
public var keyQuotaStatus: OpenRouterKeyQuotaStatus {
if self.hasValidKeyQuota {
return .available
}
guard self.keyDataFetched else {
return .unavailable
}
if let keyLimit, keyLimit > 0 {
return .unavailable
}
return .noLimitConfigured
}
public var keyRemaining: Double? {
guard self.hasValidKeyQuota,
let keyLimit,
let keyUsage
else {
return nil
}
return max(0, keyLimit - keyUsage)
}
public var keyUsedPercent: Double? {
guard self.hasValidKeyQuota,
let keyLimit,
let keyUsage
else {
return nil
}
return min(100, max(0, (keyUsage / keyLimit) * 100))
}
}
extension OpenRouterUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
let primary: RateWindow? = if let keyUsedPercent {
RateWindow(
usedPercent: keyUsedPercent,
windowMinutes: nil,
resetsAt: nil,
resetDescription: nil)
} else {
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
}
try OpenRouterSettingsReader.validateEndpointOverrides(environment: environment)
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 response = try await ProviderHTTPClient.shared.response(for: request)
let data = response.data
guard response.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 \(response.statusCode): \(errorSummary)")
throw OpenRouterUsageError.apiError("HTTP \(response.statusCode)")
}
do {
let decoder = JSONDecoder()
let creditsResponse = try decoder.decode(OpenRouterCreditsResponse.self, from: data)
// Optionally fetch key quota/rate-limit info from /key endpoint, but keep this bounded so
// credits updates are not blocked by a slow or unavailable secondary endpoint.
let keyFetch = try await fetchKeyData(
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,
keyDataFetched: keyFetch.fetched,
keyLimit: keyFetch.data?.limit,
keyUsage: keyFetch.data?.usage,
keyUsageDaily: keyFetch.data?.usageDaily,
keyUsageWeekly: keyFetch.data?.usageWeekly,
keyUsageMonthly: keyFetch.data?.usageMonthly,
rateLimit: keyFetch.data?.rateLimit,
updatedAt: Date())
} catch is CancellationError {
throw CancellationError()
} 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 key quota/rate-limit info from /key endpoint
private struct OpenRouterKeyFetchResult {
let data: OpenRouterKeyData?
let fetched: Bool
}
private static func fetchKeyData(
apiKey: String,
baseURL: URL,
timeoutSeconds: TimeInterval) async throws -> OpenRouterKeyFetchResult
{
let timeout = max(0.1, timeoutSeconds)
return try await self.boundedKeyFetch(timeout: .seconds(timeout)) {
await Self.fetchKeyDataRequest(
apiKey: apiKey,
baseURL: baseURL,
timeoutSeconds: timeout)
}
}
static func _boundedKeyFetchForTesting(
timeout: Duration,
operation: @escaping @Sendable () async -> Void) async throws -> Bool
{
let result = try await self.boundedKeyFetch(timeout: timeout) {
await operation()
return OpenRouterKeyFetchResult(data: nil, fetched: true)
}
return result.fetched
}
private static func boundedKeyFetch(
timeout: Duration,
operation: @escaping @Sendable () async -> OpenRouterKeyFetchResult) async throws -> OpenRouterKeyFetchResult
{
let sourceTask = Task<OpenRouterKeyFetchResult, Error> {
await operation()
}
let race = BoundedTaskJoin(sourceTask: sourceTask)
switch await race.value(joinGrace: timeout) {
case let .value(result):
try Task.checkCancellation()
return result
case .timedOut:
try Task.checkCancellation()
Self.log.debug("OpenRouter /key enrichment timed out")
return OpenRouterKeyFetchResult(data: nil, fetched: false)
case .failure:
sourceTask.cancel()
try Task.checkCancellation()
return OpenRouterKeyFetchResult(data: nil, fetched: false)
}
}
private static func fetchKeyDataRequest(
apiKey: String,
baseURL: URL,
timeoutSeconds: TimeInterval) async -> OpenRouterKeyFetchResult
{
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 response = try await ProviderHTTPClient.shared.response(for: request)
guard response.statusCode == 200 else {
return OpenRouterKeyFetchResult(data: nil, fetched: false)
}
let decoder = JSONDecoder()
let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: response.data)
return OpenRouterKeyFetchResult(data: keyResponse.data, fetched: true)
} catch {
Self.log.debug("Failed to fetch OpenRouter /key enrichment: \(error.localizedDescription)")
return OpenRouterKeyFetchResult(data: nil, fetched: false)
}
}
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[..<index])… [truncated]"
}
private static func redactedDebugResponseBody(_ data: Data) -> 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[..<index])… [truncated]"
}
private static func redactSensitiveBodyContent(_ text: String) -> 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)"
}
}
}