346 lines
12 KiB
Swift
346 lines
12 KiB
Swift
import CoreFoundation
|
|
import Foundation
|
|
|
|
public enum DevinUsageError: LocalizedError, Sendable {
|
|
case noSession
|
|
case missingOrganization
|
|
case invalidCredentials
|
|
case apiError(String)
|
|
case parseFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noSession:
|
|
"No Devin browser session found. Please log in to app.devin.ai or paste a Bearer token."
|
|
case .missingOrganization:
|
|
"No Devin organization was found. Open an app.devin.ai/org/... page " +
|
|
"or set the organization in Devin settings."
|
|
case .invalidCredentials:
|
|
"Devin session token is invalid or expired."
|
|
case let .apiError(message):
|
|
"Devin API error: \(message)"
|
|
case let .parseFailed(message):
|
|
"Could not parse Devin usage: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct DevinQuotaWindow: Sendable, Equatable {
|
|
public let usedPercent: Double
|
|
public let resetsAt: Date?
|
|
|
|
public init(usedPercent: Double, resetsAt: Date? = nil) {
|
|
self.usedPercent = min(100, max(0, usedPercent))
|
|
self.resetsAt = resetsAt
|
|
}
|
|
}
|
|
|
|
public struct DevinUsageSnapshot: Sendable, Equatable {
|
|
public let daily: DevinQuotaWindow?
|
|
public let weekly: DevinQuotaWindow?
|
|
public let planName: String?
|
|
public let organization: String?
|
|
public let updatedAt: Date
|
|
public let overageBalance: Double?
|
|
|
|
public init(
|
|
daily: DevinQuotaWindow?,
|
|
weekly: DevinQuotaWindow?,
|
|
planName: String?,
|
|
organization: String?,
|
|
updatedAt: Date,
|
|
overageBalance: Double? = nil)
|
|
{
|
|
self.daily = daily
|
|
self.weekly = weekly
|
|
self.planName = planName
|
|
self.organization = organization
|
|
self.updatedAt = updatedAt
|
|
self.overageBalance = overageBalance
|
|
}
|
|
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
let primary = self.daily.map {
|
|
RateWindow(
|
|
usedPercent: $0.usedPercent,
|
|
windowMinutes: 24 * 60,
|
|
resetsAt: $0.resetsAt,
|
|
resetDescription: "Daily")
|
|
}
|
|
let secondary = self.weekly.map {
|
|
RateWindow(
|
|
usedPercent: $0.usedPercent,
|
|
windowMinutes: 7 * 24 * 60,
|
|
resetsAt: $0.resetsAt,
|
|
resetDescription: "Weekly")
|
|
}
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .devin,
|
|
accountEmail: nil,
|
|
accountOrganization: self.organization,
|
|
loginMethod: self.planName)
|
|
let providerCost: ProviderCostSnapshot? = self.overageBalance.map {
|
|
ProviderCostSnapshot(
|
|
used: $0,
|
|
limit: 0,
|
|
currencyCode: "USD",
|
|
period: "Extra usage balance",
|
|
updatedAt: self.updatedAt)
|
|
}
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
providerCost: providerCost,
|
|
updatedAt: self.updatedAt,
|
|
identity: identity)
|
|
}
|
|
}
|
|
|
|
public enum DevinUsageParser {
|
|
public static func parse(_ data: Data, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot {
|
|
let object = try JSONSerialization.jsonObject(with: data)
|
|
return try self.parse(object, organization: organization, now: now)
|
|
}
|
|
|
|
public static func parse(_ object: Any, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot {
|
|
let current = (object as? [String: Any]).map(self.currentQuotaWindows)
|
|
let daily = current?.daily ?? self.findWindow(in: object, matching: self.isDailyKey)
|
|
let weekly = current?.weekly ?? self.findWindow(in: object, matching: self.isWeeklyKey)
|
|
guard daily != nil || weekly != nil else {
|
|
throw DevinUsageError.parseFailed("Missing Devin quota windows.")
|
|
}
|
|
|
|
return DevinUsageSnapshot(
|
|
daily: daily,
|
|
weekly: weekly,
|
|
planName: self.findPlanName(in: object),
|
|
organization: self.displayOrganization(from: organization),
|
|
updatedAt: now,
|
|
overageBalance: self.findOverageBalance(in: object))
|
|
}
|
|
|
|
private static func findOverageBalance(in object: Any) -> Double? {
|
|
guard let dictionary = object as? [String: Any] else { return nil }
|
|
if let value = self.nonnegativeFiniteDouble(dictionary["overage_balance"]) { return value }
|
|
if let cents = self.nonnegativeFiniteDouble(dictionary["overage_balance_cents"]) { return cents / 100.0 }
|
|
return nil
|
|
}
|
|
|
|
private static func nonnegativeFiniteDouble(_ value: Any?) -> Double? {
|
|
guard let value = self.double(value), value.isFinite, value >= 0 else { return nil }
|
|
return value
|
|
}
|
|
|
|
private static func currentQuotaWindows(_ dictionary: [String: Any])
|
|
-> (daily: DevinQuotaWindow?, weekly: DevinQuotaWindow?)
|
|
{
|
|
let daily = self.currentQuotaWindow(
|
|
percent: dictionary["daily_percentage"],
|
|
resetsAt: dictionary["daily_reset_at"])
|
|
let weekly = self.currentQuotaWindow(
|
|
percent: dictionary["weekly_percentage"],
|
|
resetsAt: dictionary["weekly_reset_at"])
|
|
return (daily, weekly)
|
|
}
|
|
|
|
private static func currentQuotaWindow(percent: Any?, resetsAt: Any?) -> DevinQuotaWindow? {
|
|
guard let usedPercent = self.double(percent) else { return nil }
|
|
return DevinQuotaWindow(
|
|
usedPercent: usedPercent < 1 ? usedPercent * 100 : usedPercent,
|
|
resetsAt: self.date(from: resetsAt))
|
|
}
|
|
|
|
private static func findWindow(in object: Any, matching keyMatches: (String) -> Bool) -> DevinQuotaWindow? {
|
|
if let dictionary = object as? [String: Any] {
|
|
for (key, value) in dictionary where keyMatches(key) {
|
|
if let window = self.window(from: value) {
|
|
return window
|
|
}
|
|
}
|
|
for value in dictionary.values {
|
|
if let found = self.findWindow(in: value, matching: keyMatches) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
|
|
if let array = object as? [Any] {
|
|
for value in array {
|
|
if let found = self.findWindow(in: value, matching: keyMatches) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func window(from object: Any) -> DevinQuotaWindow? {
|
|
guard let dictionary = object as? [String: Any] else {
|
|
guard let percent = self.percent(from: object) else { return nil }
|
|
return DevinQuotaWindow(usedPercent: percent, resetsAt: nil)
|
|
}
|
|
|
|
if let percent = self.percent(from: dictionary) {
|
|
return DevinQuotaWindow(
|
|
usedPercent: percent,
|
|
resetsAt: self.findResetDate(in: dictionary))
|
|
}
|
|
|
|
if let nested = dictionary.values.lazy.compactMap({ self.window(from: $0) }).first {
|
|
return nested
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func percent(from object: Any) -> Double? {
|
|
if let number = self.double(object) {
|
|
return number <= 1 ? number * 100 : number
|
|
}
|
|
guard let dictionary = object as? [String: Any] else { return nil }
|
|
|
|
let directKeys = [
|
|
"used_percent",
|
|
"usedPercent",
|
|
"usage_percent",
|
|
"usagePercent",
|
|
"percent_used",
|
|
"percentUsed",
|
|
"percent",
|
|
]
|
|
for key in directKeys {
|
|
if let value = self.double(dictionary[key]) {
|
|
return value <= 1 ? value * 100 : value
|
|
}
|
|
}
|
|
|
|
let remainingKeys = ["remaining_percent", "remainingPercent", "percent_remaining", "percentRemaining"]
|
|
for key in remainingKeys {
|
|
if let value = self.double(dictionary[key]) {
|
|
let percent = value <= 1 ? value * 100 : value
|
|
return 100 - percent
|
|
}
|
|
}
|
|
|
|
let used = self.firstDouble(in: dictionary, keys: ["used", "usage", "used_count", "usedCount", "consumed"])
|
|
let limit = self.firstDouble(in: dictionary, keys: ["limit", "quota", "total", "max", "available"])
|
|
if let used, let limit, limit > 0 {
|
|
return used / limit * 100
|
|
}
|
|
|
|
let remaining = self.firstDouble(in: dictionary, keys: ["remaining", "left", "available"])
|
|
if let remaining, let limit, limit > 0 {
|
|
return (limit - remaining) / limit * 100
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func findPlanName(in object: Any) -> String? {
|
|
if let dictionary = object as? [String: Any] {
|
|
for key in ["plan_name", "planName", "plan", "tier", "subscription_tier", "subscriptionTier"] {
|
|
if let value = dictionary[key] as? String,
|
|
let cleaned = self.cleanDisplay(value)
|
|
{
|
|
return cleaned
|
|
}
|
|
}
|
|
for value in dictionary.values {
|
|
if let found = self.findPlanName(in: value) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
|
|
if let array = object as? [Any] {
|
|
for value in array {
|
|
if let found = self.findPlanName(in: value) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func findResetDate(in dictionary: [String: Any]) -> Date? {
|
|
for (key, value) in dictionary where key.localizedCaseInsensitiveContains("reset") {
|
|
if let date = self.date(from: value) {
|
|
return date
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func date(from value: Any?) -> Date? {
|
|
if let raw = value as? String {
|
|
if let date = ISO8601DateFormatter().date(from: raw) {
|
|
return date
|
|
}
|
|
if let number = Double(raw) {
|
|
return self.date(from: number)
|
|
}
|
|
}
|
|
if let number = self.double(value) {
|
|
return self.date(from: number)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func date(from number: Double) -> Date? {
|
|
guard number > 0 else { return nil }
|
|
let seconds = number > 10_000_000_000 ? number / 1000 : number
|
|
return Date(timeIntervalSince1970: seconds)
|
|
}
|
|
|
|
private static func firstDouble(in dictionary: [String: Any], keys: [String]) -> Double? {
|
|
for key in keys {
|
|
if let value = self.double(dictionary[key]) {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func double(_ value: Any?) -> Double? {
|
|
switch value {
|
|
case let number as NSNumber:
|
|
CFGetTypeID(number) == CFBooleanGetTypeID() ? nil : number.doubleValue
|
|
case let string as String:
|
|
Double(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
default:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private static func isDailyKey(_ raw: String) -> Bool {
|
|
let key = raw.lowercased()
|
|
return !key.contains("hide") && (key.contains("daily") || key.contains("day"))
|
|
}
|
|
|
|
private static func isWeeklyKey(_ raw: String) -> Bool {
|
|
let key = raw.lowercased()
|
|
return !key.contains("hide") && (key.contains("weekly") || key.contains("week"))
|
|
}
|
|
|
|
private static func displayOrganization(from raw: String?) -> String? {
|
|
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
|
if raw.hasPrefix("org/") {
|
|
return String(raw.dropFirst(4))
|
|
}
|
|
if raw.hasPrefix("organizations/") {
|
|
return String(raw.dropFirst("organizations/".count))
|
|
}
|
|
return raw
|
|
}
|
|
|
|
private static func cleanDisplay(_ raw: String) -> String? {
|
|
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !cleaned.isEmpty else { return nil }
|
|
return cleaned.split(separator: "_").flatMap { $0.split(separator: "-") }.map { part in
|
|
part.prefix(1).uppercased() + String(part.dropFirst())
|
|
}.joined(separator: " ")
|
|
}
|
|
}
|