Files
2026-07-13 12:22:33 +08:00

187 lines
6.8 KiB
Swift

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)
}
}