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

418 lines
16 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - Wire DTOs (CrossModel returns integer micro units; 1 major currency unit = 1_000_000 micro)
/// `GET /v1/credits` response.
struct CrossModelCreditsResponse: Decodable {
let currency: String
let balanceMicro: Int64
let uncollectedMicro: Int64
private enum CodingKeys: String, CodingKey {
case currency
case balanceMicro = "balance_micro"
case uncollectedMicro = "uncollected_micro"
}
}
/// One usage window from `GET /v1/usage`.
struct CrossModelUsageWindowDTO: Decodable {
let costMicro: Int64
let promptTokens: Int64
let completionTokens: Int64
let totalTokens: Int64
let requestCount: Int64
let successCount: Int64
private enum CodingKeys: String, CodingKey {
case costMicro = "cost_micro"
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
case requestCount = "request_count"
case successCount = "success_count"
}
}
/// `GET /v1/usage` response.
struct CrossModelUsageResponse: Decodable {
let currency: String
let daily: CrossModelUsageWindowDTO
let weekly: CrossModelUsageWindowDTO
let monthly: CrossModelUsageWindowDTO
}
// MARK: - Snapshot (currency-normalized, persisted in UsageSnapshot)
/// One usage window with cost converted to the response currency's major unit.
public struct CrossModelUsageWindow: Codable, Sendable, Equatable {
public let cost: Double
public let promptTokens: Int
public let completionTokens: Int
public let totalTokens: Int
public let requestCount: Int
public let successCount: Int
public init(
cost: Double,
promptTokens: Int,
completionTokens: Int,
totalTokens: Int,
requestCount: Int,
successCount: Int)
{
self.cost = cost
self.promptTokens = promptTokens
self.completionTokens = completionTokens
self.totalTokens = totalTokens
self.requestCount = requestCount
self.successCount = successCount
}
init(dto: CrossModelUsageWindowDTO) {
self.cost = CrossModelUsageSnapshot.majorUnits(dto.costMicro)
self.promptTokens = Int(dto.promptTokens)
self.completionTokens = Int(dto.completionTokens)
self.totalTokens = Int(dto.totalTokens)
self.requestCount = Int(dto.requestCount)
self.successCount = Int(dto.successCount)
}
}
/// Complete CrossModel usage snapshot: wallet balance plus UTC day/week/month spend.
public struct CrossModelUsageSnapshot: Codable, Sendable, Equatable {
public let currency: String
public let balance: Double
public let uncollected: Double
public let daily: CrossModelUsageWindow?
public let weekly: CrossModelUsageWindow?
public let monthly: CrossModelUsageWindow?
public let updatedAt: Date
public init(
currency: String,
balance: Double,
uncollected: Double,
daily: CrossModelUsageWindow?,
weekly: CrossModelUsageWindow?,
monthly: CrossModelUsageWindow?,
updatedAt: Date)
{
self.currency = Self.normalizedCurrency(currency)
self.balance = balance
self.uncollected = uncollected
self.daily = daily
self.weekly = weekly
self.monthly = monthly
self.updatedAt = updatedAt
}
static func majorUnits(_ micro: Int64) -> Double {
Double(micro) / 1_000_000.0
}
static func normalizedCurrency(_ raw: String) -> String {
raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
/// Formatted balance for provider-owned menu/status display.
public var balanceDisplay: String {
self.currencyString(self.balance)
}
public func currencyString(_ value: Double) -> String {
UsageFormatter.currencyString(value, currencyCode: self.currency)
}
}
extension CrossModelUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
let identity = ProviderIdentitySnapshot(
providerID: .crossmodel,
accountEmail: nil,
accountOrganization: nil,
loginMethod: "API key")
return UsageSnapshot(
primary: nil,
secondary: nil,
tertiary: nil,
crossModelUsage: self,
updatedAt: self.updatedAt,
identity: identity,
dataConfidence: .exact)
}
}
// MARK: - Fetcher
/// Fetches balance + usage stats from the CrossModel API.
public struct CrossModelUsageFetcher: Sendable {
private static let log = CodexBarLog.logger(LogCategories.crossModelUsage)
private static let creditsRequestTimeoutSeconds: TimeInterval = 15
private static let usageRequestTimeoutSeconds: TimeInterval = 3
private static let maxErrorBodyLength = 240
/// Fetches balance (required) and usage windows (best-effort) using the provided API key.
public static func fetchUsage(
apiKey: String,
environment: [String: String] = ProcessInfo.processInfo.environment,
includeOptionalUsage: Bool = true,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
usageJoinGrace: Duration = .seconds(3)) async throws -> CrossModelUsageSnapshot
{
guard !apiKey.isEmpty else {
throw CrossModelUsageError.invalidCredentials
}
try CrossModelSettingsReader.validateEndpointOverrides(environment: environment)
let baseURL = CrossModelSettingsReader.apiURL(environment: environment)
let credits = try await self.fetchCredits(apiKey: apiKey, baseURL: baseURL, transport: transport)
let currency = try self.normalizedResponseCurrency(credits.currency, endpoint: "credits")
guard includeOptionalUsage else {
return CrossModelUsageSnapshot(
currency: currency,
balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro),
uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro),
daily: nil,
weekly: nil,
monthly: nil,
updatedAt: Date())
}
// Usage windows are best-effort: a slow or failing /usage call should not
// block the balance the user came to see.
let usage = try await self.fetchUsageWindows(
apiKey: apiKey,
baseURL: baseURL,
transport: transport,
joinGrace: usageJoinGrace)
let validatedUsage: CrossModelUsageResponse?
if let usage {
let usageCurrency: String
do {
usageCurrency = try self.normalizedResponseCurrency(usage.currency, endpoint: "usage")
} catch {
Self.log.error("CrossModel /usage omitted: invalid currency")
validatedUsage = nil
return CrossModelUsageSnapshot(
currency: currency,
balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro),
uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro),
daily: nil,
weekly: nil,
monthly: nil,
updatedAt: Date())
}
guard usageCurrency == currency else {
Self.log.error("CrossModel currency mismatch: credits=\(currency) usage=\(usageCurrency)")
return CrossModelUsageSnapshot(
currency: currency,
balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro),
uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro),
daily: nil,
weekly: nil,
monthly: nil,
updatedAt: Date())
}
validatedUsage = usage
} else {
validatedUsage = nil
}
return CrossModelUsageSnapshot(
currency: currency,
balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro),
uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro),
daily: validatedUsage.map { CrossModelUsageWindow(dto: $0.daily) },
weekly: validatedUsage.map { CrossModelUsageWindow(dto: $0.weekly) },
monthly: validatedUsage.map { CrossModelUsageWindow(dto: $0.monthly) },
updatedAt: Date())
}
private static func fetchCredits(
apiKey: String,
baseURL: URL,
transport: any ProviderHTTPTransport) async throws -> CrossModelCreditsResponse
{
let url = baseURL.appendingPathComponent("credits")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = Self.creditsRequestTimeoutSeconds
let response = try await transport.response(for: request)
guard response.statusCode == 200 else {
let summary = LogRedactor.redact(Self.sanitizedResponseBodySummary(response.data))
Self.log.error("CrossModel /credits returned \(response.statusCode): \(summary)")
if response.statusCode == 401 || response.statusCode == 403 {
throw CrossModelUsageError.invalidCredentials
}
throw CrossModelUsageError.apiError("HTTP \(response.statusCode)")
}
try self.validateSameOrigin(response: response, request: request, endpoint: "credits")
do {
return try JSONDecoder().decode(CrossModelCreditsResponse.self, from: response.data)
} catch let error as DecodingError {
Self.log.error("CrossModel /credits decode error: \(error.localizedDescription)")
throw CrossModelUsageError.parseFailed(error.localizedDescription)
}
}
private static func fetchUsageWindows(
apiKey: String,
baseURL: URL,
transport: any ProviderHTTPTransport,
joinGrace: Duration) async throws -> CrossModelUsageResponse?
{
let url = baseURL.appendingPathComponent("usage")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = Self.usageRequestTimeoutSeconds
let sourceTask = Task<CrossModelUsageResponse?, Error> {
let response = try await transport.response(for: request)
guard response.statusCode == 200 else {
Self.log.debug("CrossModel /usage enrichment returned \(response.statusCode)")
return nil
}
try self.validateSameOrigin(response: response, request: request, endpoint: "usage")
return try JSONDecoder().decode(CrossModelUsageResponse.self, from: response.data)
}
let join = BoundedTaskJoin(sourceTask: sourceTask)
let outcome = await join.value(joinGrace: joinGrace)
do {
switch outcome {
case let .value(response):
return response
case .timedOut:
Self.log.debug("Timed out fetching CrossModel /usage enrichment")
return nil
case let .failure(error):
throw error
}
} catch is CancellationError {
throw CancellationError()
} catch let error as URLError where error.code == .cancelled {
throw CancellationError()
} catch {
if Task.isCancelled {
throw CancellationError()
}
Self.log.debug("Failed to fetch CrossModel /usage enrichment: \(error.localizedDescription)")
return nil
}
}
private static func normalizedResponseCurrency(_ raw: String, endpoint: String) throws -> String {
let currency = CrossModelUsageSnapshot.normalizedCurrency(raw)
guard !currency.isEmpty else {
throw CrossModelUsageError.parseFailed("Missing CrossModel /\(endpoint) currency")
}
return currency
}
private static func validateSameOrigin(
response: ProviderHTTPResponse,
request: URLRequest,
endpoint: String) throws
{
guard let requestURL = request.url,
let responseURL = response.response.url,
let requestHost = requestURL.host?.lowercased(),
let responseHost = responseURL.host?.lowercased(),
requestURL.scheme?.lowercased() == responseURL.scheme?.lowercased(),
requestHost == responseHost,
self.effectivePort(for: requestURL) == self.effectivePort(for: responseURL)
else {
throw CrossModelUsageError.apiError("CrossModel /\(endpoint) redirected to a different origin")
}
}
private static func effectivePort(for url: URL) -> Int? {
if let port = url.port {
return port
}
switch url.scheme?.lowercased() {
case "https":
return 443
case "http":
return 80
default:
return nil
}
}
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 redactSensitiveBodyContent(_ text: String) -> String {
let replacements: [(String, String)] = [
(#"(?i)(bearer\s+)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"),
(#"(?i)(cm-)[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))
}
#endif
}
/// Errors that can occur during CrossModel usage fetching.
public enum CrossModelUsageError: LocalizedError, Sendable, Equatable {
case invalidCredentials
case networkError(String)
case apiError(String)
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .invalidCredentials:
"Invalid CrossModel API credentials"
case let .networkError(message):
"CrossModel network error: \(message)"
case let .apiError(message):
"CrossModel API error: \(message)"
case let .parseFailed(message):
"Failed to parse CrossModel response: \(message)"
}
}
}