chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
public enum CodebuffProviderDescriptor {
|
||||
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
|
||||
|
||||
static func makeDescriptor() -> ProviderDescriptor {
|
||||
ProviderDescriptor(
|
||||
id: .codebuff,
|
||||
metadata: ProviderMetadata(
|
||||
id: .codebuff,
|
||||
displayName: "Codebuff",
|
||||
sessionLabel: "Credits",
|
||||
weeklyLabel: "Weekly",
|
||||
opusLabel: nil,
|
||||
supportsOpus: false,
|
||||
supportsCredits: true,
|
||||
creditsHint: "Credit balance from the Codebuff API",
|
||||
toggleTitle: "Show Codebuff usage",
|
||||
cliName: "codebuff",
|
||||
defaultEnabled: false,
|
||||
isPrimaryProvider: false,
|
||||
usesAccountFallback: false,
|
||||
browserCookieOrder: nil,
|
||||
dashboardURL: "https://www.codebuff.com/usage",
|
||||
statusPageURL: nil,
|
||||
statusLinkURL: nil),
|
||||
branding: ProviderBranding(
|
||||
iconStyle: .codebuff,
|
||||
iconResourceName: "ProviderIcon-codebuff",
|
||||
color: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255)),
|
||||
tokenCost: ProviderTokenCostConfig(
|
||||
supportsTokenCost: false,
|
||||
noDataMessage: { "Codebuff cost summary is not yet supported." }),
|
||||
fetchPlan: ProviderFetchPlan(
|
||||
sourceModes: [.auto, .api],
|
||||
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CodebuffAPIFetchStrategy()] })),
|
||||
cli: ProviderCLIConfig(
|
||||
name: "codebuff",
|
||||
aliases: ["manicode"],
|
||||
versionDetector: nil))
|
||||
}
|
||||
}
|
||||
|
||||
struct CodebuffAPIFetchStrategy: ProviderFetchStrategy {
|
||||
let id: String = "codebuff.api"
|
||||
let kind: ProviderFetchKind = .apiToken
|
||||
|
||||
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
|
||||
_ = context
|
||||
// Keep the strategy available so missing-token surfaces as a user-friendly error
|
||||
// instead of a generic "no strategy" outcome.
|
||||
return true
|
||||
}
|
||||
|
||||
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
|
||||
guard let resolution = Self.resolveToken(environment: context.env) else {
|
||||
throw CodebuffUsageError.missingCredentials
|
||||
}
|
||||
let usage = try await CodebuffUsageFetcher.fetchUsage(
|
||||
apiKey: resolution.token,
|
||||
environment: context.env,
|
||||
includeSubscription: Self.shouldFetchSubscription(for: resolution))
|
||||
return self.makeResult(
|
||||
usage: usage.toUsageSnapshot(),
|
||||
sourceLabel: "api")
|
||||
}
|
||||
|
||||
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
static func shouldFetchSubscription(for resolution: ProviderTokenResolution) -> Bool {
|
||||
resolution.source == .authFile
|
||||
}
|
||||
|
||||
private static func resolveToken(environment: [String: String]) -> ProviderTokenResolution? {
|
||||
ProviderTokenResolver.codebuffResolution(environment: environment)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors related to Codebuff settings.
|
||||
public enum CodebuffSettingsError: LocalizedError, Sendable, Equatable {
|
||||
case missingToken
|
||||
case invalidEndpointOverride(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingToken:
|
||||
"Codebuff API token not configured. Set CODEBUFF_API_KEY or run `codebuff login` to " +
|
||||
"populate ~/.config/manicode/credentials.json."
|
||||
case let .invalidEndpointOverride(key):
|
||||
"Codebuff endpoint override \(key) must use HTTPS or a bare host."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
/// Reads Codebuff settings from the environment or the local credentials file
|
||||
/// that the `codebuff` CLI (formerly `manicode`) writes when the user logs in.
|
||||
public enum CodebuffSettingsReader {
|
||||
/// Environment variable key for the Codebuff API token.
|
||||
public static let apiTokenKey = "CODEBUFF_API_KEY"
|
||||
|
||||
/// Returns the API token from environment if present and non-empty.
|
||||
public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
||||
self.cleaned(environment[self.apiTokenKey])
|
||||
}
|
||||
|
||||
/// Returns the API base URL, defaulting to the production endpoint.
|
||||
public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
|
||||
if let override = self.validAPIURL(environment: environment) {
|
||||
return override
|
||||
}
|
||||
return URL(string: "https://www.codebuff.com")!
|
||||
}
|
||||
|
||||
public static func validateEndpointOverrides(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment) throws
|
||||
{
|
||||
guard let raw = self.cleaned(environment["CODEBUFF_API_URL"]) else { return }
|
||||
guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) == nil else { return }
|
||||
throw CodebuffSettingsError.invalidEndpointOverride("CODEBUFF_API_URL")
|
||||
}
|
||||
|
||||
/// Returns the auth token from the local credentials file if present.
|
||||
public static func authToken(
|
||||
authFileURL: URL? = nil,
|
||||
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String?
|
||||
{
|
||||
let fileURL = authFileURL ?? self.defaultAuthFileURL(homeDirectory: homeDirectory)
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
return self.parseAuthToken(data: data)
|
||||
}
|
||||
|
||||
/// Default on-disk credentials path: `~/.config/manicode/credentials.json`.
|
||||
static func defaultAuthFileURL(homeDirectory: URL) -> URL {
|
||||
homeDirectory
|
||||
.appendingPathComponent(".config", isDirectory: true)
|
||||
.appendingPathComponent("manicode", isDirectory: true)
|
||||
.appendingPathComponent("credentials.json", isDirectory: false)
|
||||
}
|
||||
|
||||
static func parseAuthToken(data: Data) -> String? {
|
||||
guard let payload = try? JSONDecoder().decode(CredentialsFile.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return self.cleaned(payload.default?.authToken) ?? self.cleaned(payload.authToken)
|
||||
}
|
||||
|
||||
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 = String(value.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private static func validAPIURL(environment: [String: String]) -> URL? {
|
||||
guard let raw = self.cleaned(environment["CODEBUFF_API_URL"]) else { return nil }
|
||||
return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CredentialsFile: Decodable {
|
||||
let `default`: CredentialsProfile?
|
||||
let authToken: String?
|
||||
}
|
||||
|
||||
private struct CredentialsProfile: Decodable {
|
||||
let authToken: String?
|
||||
let fingerprintId: String?
|
||||
let email: String?
|
||||
let name: String?
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public enum CodebuffUsageError: LocalizedError, Sendable, Equatable {
|
||||
case missingCredentials
|
||||
case unauthorized
|
||||
case endpointNotFound
|
||||
case serviceUnavailable(Int)
|
||||
case apiError(Int)
|
||||
case networkError(String)
|
||||
case parseFailed(String)
|
||||
|
||||
public static let missingToken: CodebuffUsageError = .missingCredentials
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingCredentials:
|
||||
"Codebuff API token not configured. Set CODEBUFF_API_KEY or run `codebuff login` to " +
|
||||
"populate ~/.config/manicode/credentials.json."
|
||||
case .unauthorized:
|
||||
"Unauthorized. Please sign in to Codebuff again."
|
||||
case .endpointNotFound:
|
||||
"Codebuff usage endpoint not found."
|
||||
case let .serviceUnavailable(status):
|
||||
"Codebuff API is temporarily unavailable (status \(status))."
|
||||
case let .apiError(status):
|
||||
"Codebuff API returned an unexpected status (\(status))."
|
||||
case let .networkError(message):
|
||||
"Codebuff API error: \(message)"
|
||||
case let .parseFailed(message):
|
||||
"Could not parse Codebuff usage: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
public var isAuthRelated: Bool {
|
||||
switch self {
|
||||
case .unauthorized, .missingCredentials: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Fetches live credit balance and subscription details from the Codebuff API.
|
||||
/// Uses Bearer token auth against the public www.codebuff.com endpoints used by
|
||||
/// the dashboard + the `codebuff` CLI.
|
||||
public enum CodebuffUsageFetcher {
|
||||
private static let requestTimeoutSeconds: TimeInterval = 15
|
||||
/// Extra grace period to wait for the optional subscription endpoint after the
|
||||
/// primary usage call returns. Keeps the menu responsive when `/api/user/subscription`
|
||||
/// is slow or hangs while `/api/v1/usage` succeeds quickly.
|
||||
private static let subscriptionGraceSeconds: TimeInterval = 2
|
||||
|
||||
public static func fetchUsage(
|
||||
apiKey: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
includeSubscription: Bool = true,
|
||||
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> CodebuffUsageSnapshot
|
||||
{
|
||||
try await self.fetchUsage(
|
||||
apiKey: apiKey,
|
||||
environment: environment,
|
||||
includeSubscription: includeSubscription,
|
||||
transport: transport,
|
||||
subscriptionGrace: .seconds(self.subscriptionGraceSeconds))
|
||||
}
|
||||
|
||||
static func _fetchUsageForTesting(
|
||||
apiKey: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
includeSubscription: Bool = true,
|
||||
transport: any ProviderHTTPTransport,
|
||||
subscriptionGrace: Duration) async throws -> CodebuffUsageSnapshot
|
||||
{
|
||||
try await self.fetchUsage(
|
||||
apiKey: apiKey,
|
||||
environment: environment,
|
||||
includeSubscription: includeSubscription,
|
||||
transport: transport,
|
||||
subscriptionGrace: subscriptionGrace)
|
||||
}
|
||||
|
||||
private static func fetchUsage(
|
||||
apiKey: String,
|
||||
environment: [String: String],
|
||||
includeSubscription: Bool,
|
||||
transport: any ProviderHTTPTransport,
|
||||
subscriptionGrace: Duration) async throws -> CodebuffUsageSnapshot
|
||||
{
|
||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw CodebuffUsageError.missingCredentials
|
||||
}
|
||||
try CodebuffSettingsReader.validateEndpointOverrides(environment: environment)
|
||||
|
||||
let baseURL = CodebuffSettingsReader.apiURL(environment: environment)
|
||||
|
||||
let (usageValues, subscriptionValues) = try await self.fetchPayloads(
|
||||
apiKey: trimmed,
|
||||
baseURL: baseURL,
|
||||
includeSubscription: includeSubscription,
|
||||
transport: transport,
|
||||
subscriptionGrace: subscriptionGrace)
|
||||
|
||||
return CodebuffUsageSnapshot(
|
||||
creditsUsed: usageValues.used,
|
||||
creditsTotal: usageValues.total,
|
||||
creditsRemaining: usageValues.remaining,
|
||||
weeklyUsed: subscriptionValues?.weeklyUsed,
|
||||
weeklyLimit: subscriptionValues?.weeklyLimit,
|
||||
weeklyResetsAt: subscriptionValues?.weeklyResetsAt,
|
||||
billingPeriodEnd: subscriptionValues?.billingPeriodEnd,
|
||||
nextQuotaReset: usageValues.nextQuotaReset,
|
||||
tier: subscriptionValues?.tier,
|
||||
subscriptionStatus: subscriptionValues?.status,
|
||||
autoTopUpEnabled: usageValues.autoTopupEnabled,
|
||||
accountEmail: subscriptionValues?.email,
|
||||
updatedAt: Date())
|
||||
}
|
||||
|
||||
private static func fetchPayloads(
|
||||
apiKey: String,
|
||||
baseURL: URL,
|
||||
includeSubscription: Bool,
|
||||
transport: any ProviderHTTPTransport,
|
||||
subscriptionGrace: Duration) async throws -> (UsagePayload, SubscriptionPayload?)
|
||||
{
|
||||
guard includeSubscription else {
|
||||
return try await (
|
||||
self.fetchUsagePayload(apiKey: apiKey, baseURL: baseURL, transport: transport),
|
||||
nil)
|
||||
}
|
||||
|
||||
let subscriptionTask = Task<SubscriptionPayload?, Error> {
|
||||
try await self.fetchSubscriptionPayload(
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
transport: transport)
|
||||
}
|
||||
let usageValues: UsagePayload
|
||||
do {
|
||||
usageValues = try await withTaskCancellationHandler {
|
||||
try await self.fetchUsagePayload(
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
transport: transport)
|
||||
} onCancel: {
|
||||
subscriptionTask.cancel()
|
||||
}
|
||||
} catch {
|
||||
subscriptionTask.cancel()
|
||||
throw error
|
||||
}
|
||||
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
} catch {
|
||||
subscriptionTask.cancel()
|
||||
throw error
|
||||
}
|
||||
let race = BoundedTaskJoin(sourceTask: subscriptionTask)
|
||||
switch await race.value(joinGrace: subscriptionGrace) {
|
||||
case let .value(subscriptionValues):
|
||||
try Task.checkCancellation()
|
||||
return (usageValues, subscriptionValues)
|
||||
case .timedOut:
|
||||
try Task.checkCancellation()
|
||||
return (usageValues, nil)
|
||||
case .failure:
|
||||
subscriptionTask.cancel()
|
||||
try Task.checkCancellation()
|
||||
return (usageValues, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Endpoint helpers
|
||||
|
||||
struct UsagePayload {
|
||||
let used: Double?
|
||||
let total: Double?
|
||||
let remaining: Double?
|
||||
let nextQuotaReset: Date?
|
||||
let autoTopupEnabled: Bool?
|
||||
}
|
||||
|
||||
struct SubscriptionPayload {
|
||||
let status: String?
|
||||
let tier: String?
|
||||
let billingPeriodEnd: Date?
|
||||
let weeklyUsed: Double?
|
||||
let weeklyLimit: Double?
|
||||
let weeklyResetsAt: Date?
|
||||
let email: String?
|
||||
}
|
||||
|
||||
static func usageURL(baseURL: URL) -> URL {
|
||||
baseURL.appendingPathComponent("/api/v1/usage")
|
||||
}
|
||||
|
||||
static func subscriptionURL(baseURL: URL) -> URL {
|
||||
baseURL.appendingPathComponent("/api/user/subscription")
|
||||
}
|
||||
|
||||
static func statusError(for statusCode: Int) -> CodebuffUsageError? {
|
||||
switch statusCode {
|
||||
case 401, 403: .unauthorized
|
||||
case 404: .endpointNotFound
|
||||
case 500...599: .serviceUnavailable(statusCode)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
static func parseUsagePayload(_ data: Data) throws -> UsagePayload {
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw CodebuffUsageError.parseFailed("Invalid JSON")
|
||||
}
|
||||
|
||||
let used = self.double(from: root["usage"]) ?? self.double(from: root["used"])
|
||||
let total = self.double(from: root["quota"]) ?? self.double(from: root["limit"])
|
||||
let remaining = self.double(from: root["remainingBalance"]) ?? self.double(from: root["remaining"])
|
||||
let reset = self.date(from: root["next_quota_reset"])
|
||||
let autoTopUp = root["autoTopupEnabled"] as? Bool ?? root["auto_topup_enabled"] as? Bool
|
||||
|
||||
return UsagePayload(
|
||||
used: used,
|
||||
total: total,
|
||||
remaining: remaining,
|
||||
nextQuotaReset: reset,
|
||||
autoTopupEnabled: autoTopUp)
|
||||
}
|
||||
|
||||
static func parseSubscriptionPayload(_ data: Data) throws -> SubscriptionPayload {
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw CodebuffUsageError.parseFailed("Invalid JSON")
|
||||
}
|
||||
|
||||
let subscription = root["subscription"] as? [String: Any]
|
||||
let rateLimit = root["rateLimit"] as? [String: Any]
|
||||
|
||||
let tier = self.string(from: subscription?["displayName"])
|
||||
?? self.string(from: root["displayName"])
|
||||
?? self.string(from: subscription?["tier"])
|
||||
?? self.string(from: root["tier"])
|
||||
?? self.string(from: subscription?["scheduledTier"])
|
||||
let status = subscription?["status"] as? String
|
||||
let email = root["email"] as? String ?? (root["user"] as? [String: Any])?["email"] as? String
|
||||
let billingPeriodEnd = self.date(from: subscription?["billingPeriodEnd"])
|
||||
?? self.date(from: subscription?["currentPeriodEnd"])
|
||||
let weeklyUsed = self.double(from: rateLimit?["weeklyUsed"])
|
||||
?? self.double(from: rateLimit?["used"])
|
||||
let weeklyLimit = self.double(from: rateLimit?["weeklyLimit"])
|
||||
?? self.double(from: rateLimit?["limit"])
|
||||
let weeklyResetsAt = self.date(from: rateLimit?["weeklyResetsAt"])
|
||||
|
||||
return SubscriptionPayload(
|
||||
status: status,
|
||||
tier: tier,
|
||||
billingPeriodEnd: billingPeriodEnd,
|
||||
weeklyUsed: weeklyUsed,
|
||||
weeklyLimit: weeklyLimit,
|
||||
weeklyResetsAt: weeklyResetsAt,
|
||||
email: email)
|
||||
}
|
||||
|
||||
// MARK: - Test hooks
|
||||
|
||||
static func _parseUsagePayloadForTesting(_ data: Data) throws -> UsagePayload {
|
||||
try self.parseUsagePayload(data)
|
||||
}
|
||||
|
||||
static func _parseSubscriptionPayloadForTesting(_ data: Data) throws -> SubscriptionPayload {
|
||||
try self.parseSubscriptionPayload(data)
|
||||
}
|
||||
|
||||
static func _statusErrorForTesting(_ statusCode: Int) -> CodebuffUsageError? {
|
||||
self.statusError(for: statusCode)
|
||||
}
|
||||
|
||||
// MARK: - Networking
|
||||
|
||||
private static func fetchUsagePayload(
|
||||
apiKey: String,
|
||||
baseURL: URL,
|
||||
transport: any ProviderHTTPTransport) async throws -> UsagePayload
|
||||
{
|
||||
var request = URLRequest(url: self.usageURL(baseURL: baseURL))
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = self.requestTimeoutSeconds
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: ["fingerprintId": "codexbar-usage"])
|
||||
|
||||
let response = try await self.send(request: request, transport: transport)
|
||||
if let err = self.statusError(for: response.statusCode) {
|
||||
throw err
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
throw CodebuffUsageError.apiError(response.statusCode)
|
||||
}
|
||||
return try self.parseUsagePayload(response.data)
|
||||
}
|
||||
|
||||
private static func fetchSubscriptionPayload(
|
||||
apiKey: String,
|
||||
baseURL: URL,
|
||||
transport: any ProviderHTTPTransport) async throws -> SubscriptionPayload
|
||||
{
|
||||
var request = URLRequest(url: self.subscriptionURL(baseURL: baseURL))
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = self.requestTimeoutSeconds
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let response = try await self.send(request: request, transport: transport)
|
||||
if let err = self.statusError(for: response.statusCode) {
|
||||
throw err
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
throw CodebuffUsageError.apiError(response.statusCode)
|
||||
}
|
||||
return try self.parseSubscriptionPayload(response.data)
|
||||
}
|
||||
|
||||
private static func send(
|
||||
request: URLRequest,
|
||||
transport: any ProviderHTTPTransport) async throws -> ProviderHTTPResponse
|
||||
{
|
||||
do {
|
||||
return try await transport.response(for: request)
|
||||
} catch let error as CodebuffUsageError {
|
||||
throw error
|
||||
} catch let error as URLError where error.code == .badServerResponse {
|
||||
throw CodebuffUsageError.networkError("Invalid response")
|
||||
} catch {
|
||||
throw CodebuffUsageError.networkError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value parsing
|
||||
|
||||
private static func double(from value: Any?) -> Double? {
|
||||
switch value {
|
||||
case let number as NSNumber:
|
||||
let raw = number.doubleValue
|
||||
return raw.isFinite ? raw : nil
|
||||
case let string as String:
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let raw = Double(trimmed), raw.isFinite else { return nil }
|
||||
return raw
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func string(from value: Any?) -> String? {
|
||||
switch value {
|
||||
case let string as String:
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
case let number as NSNumber:
|
||||
let raw = number.doubleValue
|
||||
guard raw.isFinite else { return nil }
|
||||
return number.stringValue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func date(from value: Any?) -> Date? {
|
||||
switch value {
|
||||
case let string as String:
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: trimmed) {
|
||||
return date
|
||||
}
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
if let date = plain.date(from: trimmed) {
|
||||
return date
|
||||
}
|
||||
if let interval = Double(trimmed), interval.isFinite {
|
||||
return Self.dateFromNumeric(interval)
|
||||
}
|
||||
return nil
|
||||
case let number as NSNumber:
|
||||
let raw = number.doubleValue
|
||||
return raw.isFinite ? Self.dateFromNumeric(raw) : nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func dateFromNumeric(_ value: Double) -> Date? {
|
||||
if value > 10_000_000_000 {
|
||||
return Date(timeIntervalSince1970: value / 1000)
|
||||
}
|
||||
return Date(timeIntervalSince1970: value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
|
||||
/// Parsed view of a Codebuff usage + subscription response pair.
|
||||
public struct CodebuffUsageSnapshot: Sendable {
|
||||
public let creditsUsed: Double?
|
||||
public let creditsTotal: Double?
|
||||
public let creditsRemaining: Double?
|
||||
public let weeklyUsed: Double?
|
||||
public let weeklyLimit: Double?
|
||||
public let weeklyResetsAt: Date?
|
||||
public let billingPeriodEnd: Date?
|
||||
public let nextQuotaReset: Date?
|
||||
public let tier: String?
|
||||
public let subscriptionStatus: String?
|
||||
public let autoTopUpEnabled: Bool?
|
||||
public let accountEmail: String?
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
creditsUsed: Double? = nil,
|
||||
creditsTotal: Double? = nil,
|
||||
creditsRemaining: Double? = nil,
|
||||
weeklyUsed: Double? = nil,
|
||||
weeklyLimit: Double? = nil,
|
||||
weeklyResetsAt: Date? = nil,
|
||||
billingPeriodEnd: Date? = nil,
|
||||
nextQuotaReset: Date? = nil,
|
||||
tier: String? = nil,
|
||||
subscriptionStatus: String? = nil,
|
||||
autoTopUpEnabled: Bool? = nil,
|
||||
accountEmail: String? = nil,
|
||||
updatedAt: Date = Date())
|
||||
{
|
||||
self.creditsUsed = creditsUsed
|
||||
self.creditsTotal = creditsTotal
|
||||
self.creditsRemaining = creditsRemaining
|
||||
self.weeklyUsed = weeklyUsed
|
||||
self.weeklyLimit = weeklyLimit
|
||||
self.weeklyResetsAt = weeklyResetsAt
|
||||
self.billingPeriodEnd = billingPeriodEnd
|
||||
self.nextQuotaReset = nextQuotaReset
|
||||
self.tier = tier
|
||||
self.subscriptionStatus = subscriptionStatus
|
||||
self.autoTopUpEnabled = autoTopUpEnabled
|
||||
self.accountEmail = accountEmail
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
public func toUsageSnapshot() -> UsageSnapshot {
|
||||
let primary = self.makeCreditsWindow()
|
||||
let secondary = self.makeWeeklyWindow()
|
||||
|
||||
let identity = ProviderIdentitySnapshot(
|
||||
providerID: .codebuff,
|
||||
accountEmail: self.accountEmail,
|
||||
accountOrganization: nil,
|
||||
loginMethod: self.makeLoginMethod())
|
||||
|
||||
return UsageSnapshot(
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
tertiary: nil,
|
||||
providerCost: nil,
|
||||
updatedAt: self.updatedAt,
|
||||
identity: identity)
|
||||
}
|
||||
|
||||
private func makeCreditsWindow() -> RateWindow? {
|
||||
let total = self.resolvedTotal
|
||||
guard let total, total > 0 else {
|
||||
if self.creditsRemaining != nil || self.creditsUsed != nil {
|
||||
// Degenerate case: no usable quota in the payload. Surface the row as fully
|
||||
// exhausted so missing quota data is visibly surfaced (matches Kilo's behaviour
|
||||
// for zero/unknown totals) rather than rendering a misleading healthy bar.
|
||||
return RateWindow(
|
||||
usedPercent: 100,
|
||||
windowMinutes: nil,
|
||||
resetsAt: self.nextQuotaReset,
|
||||
resetDescription: nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let used = self.resolvedUsed
|
||||
let percent = min(100, max(0, (used / total) * 100))
|
||||
// Note: do not stuff the credit balance ("X/Y credits") into `resetDescription` —
|
||||
// generic renderers (UsageFormatter.resetLine) prepend "Resets " when `resetsAt`
|
||||
// is absent, which would surface misleading text like "Resets 250/1,000 credits".
|
||||
// The credits detail is shown via the dedicated Codebuff account panel instead.
|
||||
return RateWindow(
|
||||
usedPercent: percent,
|
||||
windowMinutes: nil,
|
||||
resetsAt: self.nextQuotaReset,
|
||||
resetDescription: nil)
|
||||
}
|
||||
|
||||
private func makeWeeklyWindow() -> RateWindow? {
|
||||
guard let limit = self.weeklyLimit, limit > 0 else { return nil }
|
||||
let used = max(0, self.weeklyUsed ?? 0)
|
||||
let percent = min(100, max(0, (used / limit) * 100))
|
||||
// Same reasoning as above: avoid encoding non-reset detail in `resetDescription`.
|
||||
return RateWindow(
|
||||
usedPercent: percent,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: self.weeklyResetsAt,
|
||||
resetDescription: nil)
|
||||
}
|
||||
|
||||
private var resolvedTotal: Double? {
|
||||
if let creditsTotal { return max(0, creditsTotal) }
|
||||
if let creditsUsed, let creditsRemaining {
|
||||
return max(0, creditsUsed + creditsRemaining)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var resolvedUsed: Double {
|
||||
if let creditsUsed {
|
||||
return max(0, creditsUsed)
|
||||
}
|
||||
if let total = self.resolvedTotal, let creditsRemaining {
|
||||
return max(0, total - creditsRemaining)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private func makeLoginMethod() -> String? {
|
||||
var parts: [String] = []
|
||||
if let tier = self.tier?.trimmingCharacters(in: .whitespacesAndNewlines), !tier.isEmpty {
|
||||
parts.append(tier.capitalized)
|
||||
}
|
||||
if let remaining = self.creditsRemaining {
|
||||
parts.append("\(Self.compactNumber(remaining)) remaining")
|
||||
}
|
||||
if self.autoTopUpEnabled == true {
|
||||
parts.append("auto top-up")
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
static func compactNumber(_ value: Double) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
formatter.maximumFractionDigits = value >= 1000 ? 0 : 1
|
||||
return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user