466 lines
17 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|