186 lines · 6.8 KB · Swift
RawDownload
import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif public struct CopilotUsageFetcher: Sendable {    public struct GitHubUserIdentity: Decodable, Equatable, Sendable {        public let id: Int64        public let login: String         public init(id: Int64, login: String) {            self.id = id            self.login = login        }    }     private let token: String    private let enterpriseHost: String?    private let transport: any ProviderHTTPTransport     public init(        token: String,        enterpriseHost: String? = nil,        transport: any ProviderHTTPTransport = ProviderHTTPClient.shared)    {        self.token = token        self.enterpriseHost = enterpriseHost        self.transport = transport    }     public static func apiHost(enterpriseHost: String?) -> String {        let host = CopilotDeviceFlow.normalizedHost(enterpriseHost)        if host == CopilotDeviceFlow.defaultHost {            return "api.github.com"        }        if host.hasPrefix("api.") {            return host        }        return "api.\(host)"    }     public static func usageURL(enterpriseHost: String?) -> URL? {        CopilotDeviceFlow.makeRequestURL(            host: self.apiHost(enterpriseHost: enterpriseHost),            path: "/copilot_internal/user")    }     public func fetch() async throws -> UsageSnapshot {        guard let url = Self.usageURL(enterpriseHost: self.enterpriseHost) else {            throw URLError(.badURL)        }         var request = URLRequest(url: url)        // Use the GitHub OAuth token directly, not the Copilot token.        request.setValue("token \(self.token)", forHTTPHeaderField: "Authorization")        self.addCommonHeaders(to: &request)         let response = try await self.transport.response(for: request)         if response.statusCode == 401 || response.statusCode == 403 {            throw URLError(.userAuthenticationRequired)        }         guard response.statusCode == 200 else {            throw URLError(.badServerResponse)        }         let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: response.data)        let resetsAt = Self.parseQuotaResetDate(usage.quotaResetDate)        let premium = Self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions, resetsAt: resetsAt)        let chat = Self.makeRateWindow(from: usage.quotaSnapshots.chat, resetsAt: resetsAt)         let primary: RateWindow?        let secondary: RateWindow?        if let premium {            primary = premium            secondary = chat        } else if let chatWindow = chat {            // Keep chat in the secondary slot so provider labels remain accurate            // ("Premium" for primary, "Chat" for secondary) on chat-only plans.            primary = nil            secondary = chatWindow        } else if usage.tokenBasedBilling {            // Copilot Business token-based billing currently exposes zero-entitlement            // placeholder quotas on this endpoint, so surface the plan without fake usage.            primary = nil            secondary = nil        } else {            throw URLError(.cannotDecodeRawData)        }         let identity = ProviderIdentitySnapshot(            providerID: .copilot,            accountEmail: nil,            accountOrganization: nil,            loginMethod: usage.copilotPlan.capitalized)        return UsageSnapshot(            primary: primary,            secondary: secondary,            tertiary: nil,            providerCost: nil,            updatedAt: Date(),            identity: identity)    }     public static func fetchGitHubUsername(token: String) async throws -> String {        try await self.fetchGitHubIdentity(token: token).login    }     public static func fetchGitHubIdentity(        token: String,        transport: any ProviderHTTPTransport = ProviderHTTPClient.shared)        async throws -> GitHubUserIdentity    {        guard let url = URL(string: "https://api.github.com/user") else {            throw URLError(.badURL)        }        var request = URLRequest(url: url)        request.setValue("token \(token)", forHTTPHeaderField: "Authorization")        request.setValue("application/json", forHTTPHeaderField: "Accept")         let response = try await transport.response(for: request)        switch response.statusCode {        case 200:            return try JSONDecoder().decode(GitHubUserIdentity.self, from: response.data)        case 401, 403:            throw URLError(.userAuthenticationRequired)        default:            throw URLError(.badServerResponse)        }    }     private func addCommonHeaders(to request: inout URLRequest) {        request.setValue("application/json", forHTTPHeaderField: "Accept")        request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version")        request.setValue("copilot-chat/0.26.7", forHTTPHeaderField: "Editor-Plugin-Version")        request.setValue("GitHubCopilotChat/0.26.7", forHTTPHeaderField: "User-Agent")        request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version")    }     static func makeRateWindow(        from snapshot: CopilotUsageResponse.QuotaSnapshot?,        resetsAt: Date? = nil) -> RateWindow?    {        guard let snapshot else { return nil }        guard !snapshot.isPlaceholder else { return nil }        guard snapshot.hasPercentRemaining else { return nil }        let usedPercent = snapshot.usedPercent        let overQuotaDescription = snapshot.overQuotaUsedPercent.map { used in            String(format: "%.0f%% used", used)        }        let effectiveResetsAt = snapshot.unlimited ? nil : resetsAt         return RateWindow(            usedPercent: usedPercent,            windowMinutes: nil,            resetsAt: effectiveResetsAt,            resetDescription: overQuotaDescription)    }     static func parseQuotaResetDate(_ value: String?) -> Date? {        guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {            return nil        }         let fractionalISO = ISO8601DateFormatter()        fractionalISO.formatOptions = [.withInternetDateTime, .withFractionalSeconds]        if let date = fractionalISO.date(from: raw) {            return date        }         let internetISO = ISO8601DateFormatter()        internetISO.formatOptions = [.withInternetDateTime]        if let date = internetISO.date(from: raw) {            return date        }         let formatter = DateFormatter()        formatter.calendar = Calendar(identifier: .gregorian)        formatter.isLenient = false        formatter.locale = Locale(identifier: "en_US_POSIX")        formatter.timeZone = TimeZone(secondsFromGMT: 0)        formatter.dateFormat = "yyyy-MM-dd"        return formatter.date(from: raw)    }}