mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 23:29:35 +00:00
#1286 taught the credential files to carry the GitHub host their token came from, but the environment and `gh auth token` rungs carried none, so they were always addressed to api.github.com. A user whose only Copilot credential is a GitHub Enterprise Cloud login — GH_TOKEN exported alongside GH_HOST, or `gh auth login --hostname <tenant>.ghe.com` — got a 401 and a terminal failure with nothing to act on. The environment rung now reads GH_HOST out of the same environment as the token. The gh rung resolves the host from gh's own hosts.yml, found via GH_CONFIG_DIR, then XDG_CONFIG_HOME/gh, then ~/.config/gh, and picked the way gh picks the host for `gh auth token`: GH_HOST, else the single configured host, else dotcom. That is a file read rather than a second `gh auth status` spawn, because gh writes hosts.yml for every login, keyring-backed ones included. Both are new untrusted inputs, so they stay raw all the way to `apiHost(for:)`, where #1286's character validation refuses a crafted value such as `evil.com?.ghe.com` before any URL exists — that host passes the `.ghe.com` suffix check but builds a URL whose host is api.evil.com, which would then receive the Authorization header.
604 lines
28 KiB
Swift
604 lines
28 KiB
Swift
import Foundation
|
|
|
|
/// Live quota snapshot for a GitHub Copilot plan. The API reports per-feature
|
|
/// quota snapshots ("Premium requests", "Chat") as percent REMAINING; windows
|
|
/// render percent USED, so `usedPercent` is the inverted value. `details`
|
|
/// keeps the endpoint's order (premium first), so `primary` is the premium
|
|
/// window when present and chat otherwise.
|
|
struct CopilotUsage: Sendable, Equatable {
|
|
struct Window: Sendable, Equatable {
|
|
let label: String
|
|
let usedPercent: Double // 0.0 ... 100.0
|
|
/// The endpoint reports no reset times — always nil.
|
|
let resetsAt: Date?
|
|
}
|
|
|
|
/// Premium-requests window first, then chat; either may be absent.
|
|
let details: [Window]
|
|
var primary: Window? { details.first }
|
|
/// Plan label derived from copilot_plan (Free / Individual / Pro /
|
|
/// Business / Enterprise / Educators, unknown tiers title-cased).
|
|
let plan: String?
|
|
let fetchedAt: Date
|
|
/// Host that answered, so Settings can name it: `api.github.com`, or the
|
|
/// tenant's `api.<tenant>.ghe.com` on GitHub Enterprise Cloud (#1286).
|
|
var apiHost: String = CopilotHostEndpoint.defaultAPIHost
|
|
}
|
|
|
|
/// A discovered Copilot token together with the GitHub host it belongs to, so
|
|
/// the quota request can be addressed to that host's API instead of assuming
|
|
/// dotcom (#1286).
|
|
struct CopilotCredential: Sendable, Equatable {
|
|
let token: String
|
|
/// Host the token was read from, or nil when the source carries none,
|
|
/// which is dotcom. Every rung can carry one: the credential files are
|
|
/// keyed by host, the environment rung reads `GH_HOST`, the `gh` rung
|
|
/// resolves the host of the login that answered, and a pasted token is
|
|
/// saved with the host typed next to it (#1306).
|
|
let host: String?
|
|
|
|
init(token: String, host: String? = nil) {
|
|
self.token = token
|
|
self.host = host
|
|
}
|
|
|
|
/// Normalized host, defaulting to dotcom for a source without one.
|
|
var resolvedHost: String {
|
|
CopilotHostEndpoint.normalize(host) ?? CopilotHostEndpoint.defaultHost
|
|
}
|
|
}
|
|
|
|
/// Live GitHub Copilot quota via the editor plugins' internal usage endpoint
|
|
/// (derived from observed GitHub Copilot client traffic):
|
|
///
|
|
/// - GET https://api.github.com/copilot_internal/user → plan + quota snapshots
|
|
///
|
|
/// The host is not fixed: a credential read for a GitHub Enterprise Cloud
|
|
/// tenant is queried on that tenant's own API host
|
|
/// (`https://api.<tenant>.ghe.com/copilot_internal/user`) and is never sent to
|
|
/// api.github.com. See `CopilotHostEndpoint`.
|
|
///
|
|
/// This is an INTERNAL, UNDOCUMENTED API that may drift without notice; every
|
|
/// failure must degrade to the normal connection states and never crash the
|
|
/// panel.
|
|
///
|
|
/// Credential: any GitHub token already on the machine from a signed-in
|
|
/// Copilot client. Modern clients no longer write the legacy plugin files, so
|
|
/// discovery walks an ordered chain (see `readCredential`). Read-only throughout;
|
|
/// nothing is copied or written, and there is no refresh path: an expired or
|
|
/// revoked token stays dead until the user signs in again with whichever
|
|
/// client owns it.
|
|
enum CopilotSubscriptionService {
|
|
private static let usageBlockedUntilKey = "codeburn.copilot.usage.blockedUntil"
|
|
/// Capacity Dock provider id, used for the pasted-token rung.
|
|
static let providerID = "copilot"
|
|
/// Honoured in the same order the Copilot CLI honours them.
|
|
static let environmentTokenNames = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]
|
|
|
|
enum FetchError: Error, LocalizedError {
|
|
case noCredentials
|
|
/// 401 where re-reading the chain yielded the same (or no) token. An
|
|
/// active client session rotates this token, so this is transient: the
|
|
/// next refresh picks up the rotated one from whichever source holds it.
|
|
case tokenRejected
|
|
case rateLimited(retryAt: Date)
|
|
case usageHTTPError(Int)
|
|
case usageDecodeFailed
|
|
case network(Error, host: String)
|
|
/// The credential belongs to a GitHub host whose Copilot quota
|
|
/// endpoint this build cannot derive (a self-hosted GitHub Enterprise
|
|
/// Server install, say). Never retried against api.github.com.
|
|
case unsupportedHost(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noCredentials:
|
|
return "No GitHub Copilot credentials found. Usage tracking still works. "
|
|
+ "To show live quota, sign in with the Copilot CLI, run gh auth login, "
|
|
+ "or paste a GitHub token in Settings."
|
|
case .tokenRejected:
|
|
return "GitHub rejected the Copilot token found on this Mac. It will be retried once you sign in again."
|
|
case let .rateLimited(retryAt):
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .short
|
|
return "GitHub rate-limited the Copilot quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
|
case let .usageHTTPError(code):
|
|
return "Copilot quota fetch failed (HTTP \(code)). Sign in to Copilot again, then Reconnect."
|
|
case .usageDecodeFailed:
|
|
return "Copilot quota response was malformed."
|
|
case let .network(err, host):
|
|
return "Network error reaching \(host): \(err.localizedDescription)"
|
|
case let .unsupportedHost(host):
|
|
return "Copilot quota is not available for the GitHub host \(host). "
|
|
+ "CodeBurn can read github.com and GitHub Enterprise Cloud (*.ghe.com) hosts."
|
|
}
|
|
}
|
|
|
|
var isTerminal: Bool {
|
|
switch self {
|
|
case .noCredentials, .unsupportedHost:
|
|
return true
|
|
case let .usageHTTPError(code):
|
|
return (400..<500).contains(code)
|
|
case .tokenRejected, .rateLimited, .usageDecodeFailed, .network:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var rateLimitRetryAt: Date? {
|
|
if case let .rateLimited(retryAt) = self { return retryAt }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Injectable seams (tests drive fixtures through these)
|
|
|
|
struct Deps: Sendable {
|
|
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
|
/// Read-only credential file load. Never writes.
|
|
var readFile: @Sendable (URL) -> Data?
|
|
var hostsURL: URL
|
|
var appsURL: URL
|
|
/// The Copilot CLI's config directory (~/.copilot).
|
|
var copilotDirURL: URL
|
|
/// `gh`'s `hosts.yml`, read only to learn which host the `gh` rung's
|
|
/// token belongs to. Never a token source: `gh auth token` owns that.
|
|
var ghHostsURL: URL = CopilotHostEndpoint.ghHostsFileURL()
|
|
var environment: @Sendable (String) -> String?
|
|
/// `gh auth token`. Uncached: the service owns the caching so tests can
|
|
/// drive the probe directly.
|
|
var ghAuthToken: @Sendable () -> String?
|
|
/// Token the user pasted into CodeBurn's own Copilot settings, with
|
|
/// the host saved beside it.
|
|
var savedCredential: @Sendable () -> CopilotCredential?
|
|
var now: @Sendable () -> Date
|
|
|
|
static let live = Deps(
|
|
fetch: { request in
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw FetchError.usageHTTPError(-1)
|
|
}
|
|
return (data, http)
|
|
},
|
|
readFile: { url in FileManager.default.contents(atPath: url.path) },
|
|
hostsURL: URL(fileURLWithPath: NSHomeDirectory() + "/.config/github-copilot/hosts.json"),
|
|
appsURL: URL(fileURLWithPath: NSHomeDirectory() + "/.config/github-copilot/apps.json"),
|
|
copilotDirURL: URL(fileURLWithPath: NSHomeDirectory() + "/.copilot"),
|
|
ghHostsURL: CopilotHostEndpoint.ghHostsFileURL(),
|
|
environment: { ProcessInfo.processInfo.environment[$0] },
|
|
ghAuthToken: { runGhAuthToken() },
|
|
savedCredential: { savedCopilotCredential() },
|
|
now: { Date() }
|
|
)
|
|
}
|
|
|
|
// MARK: - Credential discovery
|
|
|
|
/// Cheap eligibility answer for the dock and the initial load state. It
|
|
/// must never spawn a subprocess, so the `gh` rung is approximated by an
|
|
/// installed `gh` binary rather than an actual login probe. A false
|
|
/// positive costs one probe inside `refresh`; a false negative would
|
|
/// silently never try at all.
|
|
static var hasCredential: Bool {
|
|
let deps = Deps.live
|
|
let manager = FileManager.default
|
|
for url in [deps.hostsURL, deps.appsURL, deps.copilotDirURL]
|
|
where manager.fileExists(atPath: url.path) {
|
|
return true
|
|
}
|
|
if environmentTokenNames.contains(where: { nonEmpty(deps.environment($0)) != nil }) { return true }
|
|
if CapacityDockProviderCredentialPresence.contains(providerID) { return true }
|
|
return ghExecutableURL() != nil
|
|
}
|
|
|
|
/// Ordered, read-only credential chain; the first rung that yields a token
|
|
/// wins and every rung tolerates absent or malformed data:
|
|
///
|
|
/// 1. legacy editor-plugin files: `hosts.json` then `apps.json` (both
|
|
/// carry the GitHub host the token belongs to)
|
|
/// 2. `~/.copilot/config.json` then `~/.copilot/settings.json`
|
|
/// 3. `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, with `GH_HOST`
|
|
/// as the host those tokens belong to
|
|
/// 4. `gh auth token`, with the host taken from `gh`'s own `hosts.yml`
|
|
/// 5. a token pasted into CodeBurn's Copilot settings, with the host saved
|
|
/// beside it
|
|
///
|
|
/// Every rung can therefore name a GitHub Enterprise Cloud tenant; only a
|
|
/// source that genuinely carries no host falls back to dotcom (#1306).
|
|
///
|
|
/// The Copilot CLI's own Keychain item (service `copilot-cli`) is
|
|
/// deliberately not read: it is written by a Node keyring library, so
|
|
/// `/usr/bin/security` is not in its partition list and every read would
|
|
/// raise the "allow access?" dialog. `gh auth token` reaches the same
|
|
/// users, because the Copilot CLI itself falls back to gh.
|
|
private static func readCredential(deps: Deps) -> CopilotCredential? {
|
|
// hosts.json is keyed by GitHub host; apps.json by "<host>:<app id>"
|
|
// (older releases wrote a bare app name, which carries no host).
|
|
if let data = deps.readFile(deps.hostsURL),
|
|
let credential = credentialFromMap(data, host: { $0 }) { return credential }
|
|
if let data = deps.readFile(deps.appsURL),
|
|
let credential = credentialFromMap(data, host: { hostFromAppsKey($0) }) { return credential }
|
|
for name in ["config.json", "settings.json"] {
|
|
if let data = deps.readFile(deps.copilotDirURL.appendingPathComponent(name)),
|
|
let token = tokenFromCopilotCLIJSON(data) { return CopilotCredential(token: token) }
|
|
}
|
|
for name in environmentTokenNames {
|
|
if let token = nonEmpty(deps.environment(name)) {
|
|
return CopilotCredential(
|
|
token: token,
|
|
host: nonEmpty(deps.environment(CopilotHostEndpoint.hostEnvironmentName)))
|
|
}
|
|
}
|
|
if let token = cachedGhToken(deps: deps) {
|
|
return CopilotCredential(token: token, host: ghHost(deps: deps))
|
|
}
|
|
return deps.savedCredential()
|
|
}
|
|
|
|
/// Host of the login `gh auth token` answered for. Read from `hosts.yml`
|
|
/// rather than by spawning `gh auth status`: the `gh` rung already costs
|
|
/// one process, and `gh` writes that file for every login.
|
|
private static func ghHost(deps: Deps) -> String? {
|
|
let config = deps.readFile(deps.ghHostsURL).flatMap { String(data: $0, encoding: .utf8) }
|
|
return CopilotHostEndpoint.ghHost(
|
|
environmentHost: deps.environment(CopilotHostEndpoint.hostEnvironmentName),
|
|
hostsConfig: config)
|
|
}
|
|
|
|
/// Reads the `oauth_token` entries out of a credential map and picks one
|
|
/// with `CopilotHostEndpoint.preferredHost`, so the token we send and the
|
|
/// host we send it to always come from the same entry. Keys are visited in
|
|
/// sorted order, making the pick stable when several entries qualify.
|
|
private static func credentialFromMap(
|
|
_ data: Data,
|
|
host keyToHost: (String) -> String?
|
|
) -> CopilotCredential? {
|
|
guard let map = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
|
let entries: [CopilotCredential] = map.keys.sorted().compactMap { key in
|
|
guard let token = (map[key] as? [String: Any])?["oauth_token"] as? String, !token.isEmpty else {
|
|
return nil
|
|
}
|
|
return CopilotCredential(token: token, host: keyToHost(key))
|
|
}
|
|
guard let preferred = CopilotHostEndpoint.preferredHost(among: entries.map(\.host)) else { return nil }
|
|
return entries.first { $0.resolvedHost == preferred }
|
|
}
|
|
|
|
/// apps.json keys look like `github.com:Iv1.<app id>` on the newer
|
|
/// plugins and like a bare app name ("Visual Studio Code") on the older
|
|
/// ones; only the former carries a host.
|
|
private static func hostFromAppsKey(_ key: String) -> String? {
|
|
guard let separator = key.firstIndex(of: ":") else { return nil }
|
|
let candidate = String(key[..<separator])
|
|
return candidate.contains(".") ? candidate : nil
|
|
}
|
|
|
|
/// GitHub access-token prefixes. `ghr_` (refresh) is deliberately absent:
|
|
/// the usage endpoint rejects it.
|
|
private static let tokenPrefixes = ["gho_", "ghu_", "ghp_", "ghs_", "github_pat_"]
|
|
|
|
static func looksLikeGitHubToken(_ value: String) -> Bool {
|
|
tokenPrefixes.contains { value.hasPrefix($0) && value.count > $0.count }
|
|
}
|
|
|
|
/// The ~/.copilot schema is undocumented and the key holding the token has
|
|
/// moved between releases, so rather than guess key names take the first
|
|
/// value carrying a GitHub access-token prefix.
|
|
private static func tokenFromCopilotCLIJSON(_ data: Data) -> String? {
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) else { return nil }
|
|
return firstGitHubToken(in: json)
|
|
}
|
|
|
|
/// Dictionary keys are visited in sorted order so the pick stays stable
|
|
/// when more than one field qualifies.
|
|
private static func firstGitHubToken(in value: Any) -> String? {
|
|
switch value {
|
|
case let text as String:
|
|
return looksLikeGitHubToken(text) ? text : nil
|
|
case let array as [Any]:
|
|
return array.lazy.compactMap { firstGitHubToken(in: $0) }.first
|
|
case let map as [String: Any]:
|
|
return map.keys.sorted().lazy.compactMap { firstGitHubToken(in: map[$0] as Any) }.first
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func nonEmpty(_ value: String?) -> String? {
|
|
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
|
|
return nil
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
// MARK: - Subprocess probes
|
|
|
|
/// `gh auth token` costs a process spawn, and the panel refreshes far more
|
|
/// often than a login changes, so its answer is reused for this long.
|
|
static let probeCacheTTL: TimeInterval = 5 * 60
|
|
|
|
private final class ProbeCache: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var entry: (value: String?, at: Date)?
|
|
|
|
func value(now: Date, ttl: TimeInterval, probe: () -> String?) -> String? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if let entry, now.timeIntervalSince(entry.at) < ttl { return entry.value }
|
|
let value = probe()
|
|
entry = (value, now)
|
|
return value
|
|
}
|
|
|
|
func reset() {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
entry = nil
|
|
}
|
|
}
|
|
|
|
private static let probeCache = ProbeCache()
|
|
|
|
private static func cachedGhToken(deps: Deps) -> String? {
|
|
probeCache.value(now: deps.now(), ttl: probeCacheTTL) { nonEmpty(deps.ghAuthToken()) }
|
|
}
|
|
|
|
/// Drops the cached `gh` answer so the next read re-probes.
|
|
static func resetProbeCache() {
|
|
probeCache.reset()
|
|
}
|
|
|
|
/// `Process` is not Sendable; the watchdog only calls `terminate()`, which
|
|
/// is safe from another thread.
|
|
private final class ProcessBox: @unchecked Sendable {
|
|
let process: Process
|
|
init(_ process: Process) { self.process = process }
|
|
}
|
|
|
|
/// Runs a short-lived command and returns trimmed stdout, or nil on any
|
|
/// failure. stdin is /dev/null so a child can never block on a prompt, and
|
|
/// the deadline guarantees the caller's refresh cannot wedge.
|
|
private static func runCapturingStdout(
|
|
_ executable: URL,
|
|
_ arguments: [String],
|
|
timeout: TimeInterval = 5
|
|
) -> String? {
|
|
let process = Process()
|
|
process.executableURL = executable
|
|
process.arguments = arguments
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = FileHandle.nullDevice
|
|
process.standardInput = FileHandle.nullDevice
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
return nil
|
|
}
|
|
let box = ProcessBox(process)
|
|
let watchdog = DispatchWorkItem {
|
|
if box.process.isRunning { box.process.terminate() }
|
|
}
|
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout, execute: watchdog)
|
|
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)
|
|
process.waitUntilExit()
|
|
watchdog.cancel()
|
|
guard process.terminationStatus == 0 else { return nil }
|
|
return nonEmpty(output)
|
|
}
|
|
|
|
/// `gh auth token` resolves keyring vs ~/.config/gh/hosts.yml itself, so we
|
|
/// only have to find the binary. A missing `gh` is simply absent.
|
|
private static func runGhAuthToken() -> String? {
|
|
guard let gh = ghExecutableURL() else { return nil }
|
|
return runCapturingStdout(gh, ["auth", "token"])
|
|
}
|
|
|
|
/// Spotlight-launched apps inherit a minimal PATH, so reuse the same PATH
|
|
/// augmentation that finds the codeburn CLI (Homebrew included).
|
|
private static func ghExecutableURL(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> URL? {
|
|
let path = CodeburnCLI.augmentedPath(
|
|
environment["PATH"] ?? "",
|
|
homeDirectory: NSHomeDirectory(),
|
|
environment: environment
|
|
)
|
|
for directory in path.split(separator: ":", omittingEmptySubsequences: true) {
|
|
let candidate = "\(directory)/gh"
|
|
if FileManager.default.isExecutableFile(atPath: candidate) {
|
|
return URL(fileURLWithPath: candidate)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Gated on the non-secret presence index so the common "nothing pasted"
|
|
/// case never touches Keychain at all. The host comes out of the same
|
|
/// record as the token, so the two cannot drift; a record saved before the
|
|
/// host field existed carries none and stays dotcom.
|
|
private static func savedCopilotCredential() -> CopilotCredential? {
|
|
guard CapacityDockProviderCredentialPresence.contains(providerID) else { return nil }
|
|
let override = (try? CapacityDockProviderCredentialStore.load(for: providerID))?.sanitizedOverride
|
|
guard let token = nonEmpty(override?.apiKey) else { return nil }
|
|
return CopilotCredential(token: token, host: override?.host)
|
|
}
|
|
|
|
// MARK: - Fetch
|
|
|
|
static func refresh(deps: Deps = .live) async throws -> CopilotUsage {
|
|
if let until = usageBlockedUntil(), until > deps.now() {
|
|
throw FetchError.rateLimited(retryAt: until)
|
|
}
|
|
guard var credential = readCredential(deps: deps) else { throw FetchError.noCredentials }
|
|
|
|
var (data, response) = try await send(request(credential), deps: deps)
|
|
if response.statusCode == 401 {
|
|
// An active client session rotates this token; re-read once before
|
|
// giving up so we don't report a failure the source already fixed.
|
|
// The subprocess rungs are cached, so drop those answers first.
|
|
resetProbeCache()
|
|
guard let reread = readCredential(deps: deps), reread != credential else {
|
|
throw FetchError.tokenRejected
|
|
}
|
|
credential = reread
|
|
(data, response) = try await send(request(credential), deps: deps)
|
|
}
|
|
if response.statusCode == 429 {
|
|
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
|
|
retryAfterSeconds: parseRetryAfterHeader(response.value(forHTTPHeaderField: "Retry-After"))))
|
|
}
|
|
guard (200..<300).contains(response.statusCode) else {
|
|
throw FetchError.usageHTTPError(response.statusCode)
|
|
}
|
|
return try decodeUsage(
|
|
data: data,
|
|
now: deps.now(),
|
|
apiHost: apiHost(for: credential))
|
|
}
|
|
|
|
/// The API host a credential is queried on. Throws rather than falling
|
|
/// back to dotcom, so an enterprise token is never sent to api.github.com.
|
|
private static func apiHost(for credential: CopilotCredential) throws -> String {
|
|
guard let host = CopilotHostEndpoint.apiHost(for: credential.host) else {
|
|
throw FetchError.unsupportedHost(credential.resolvedHost)
|
|
}
|
|
return host
|
|
}
|
|
|
|
private static func request(_ credential: CopilotCredential) throws -> URLRequest {
|
|
guard let url = CopilotHostEndpoint.usageURL(for: credential.host) else {
|
|
throw FetchError.unsupportedHost(credential.resolvedHost)
|
|
}
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 30
|
|
// Headers mirror the VS Code Copilot Chat plugin.
|
|
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")
|
|
request.setValue("token \(credential.token)", forHTTPHeaderField: "Authorization")
|
|
return request
|
|
}
|
|
|
|
private static func send(_ request: URLRequest, deps: Deps) async throws -> (Data, HTTPURLResponse) {
|
|
do {
|
|
return try await deps.fetch(request)
|
|
} catch let error as FetchError {
|
|
throw error
|
|
} catch {
|
|
// Name the host: on an unreachable enterprise tenant this is the
|
|
// only place the user learns which endpoint was tried.
|
|
throw FetchError.network(error, host: request.url?.host ?? CopilotHostEndpoint.defaultAPIHost)
|
|
}
|
|
}
|
|
|
|
// MARK: - Decode (internal so tests can drive fixtures)
|
|
|
|
static func decodeUsage(
|
|
data: Data,
|
|
now: Date = Date(),
|
|
apiHost: String = CopilotHostEndpoint.defaultAPIHost
|
|
) throws -> CopilotUsage {
|
|
let parsed: Any
|
|
do {
|
|
parsed = try JSONSerialization.jsonObject(with: data)
|
|
} catch {
|
|
// Never log the body — account data readable via `log stream`.
|
|
throw FetchError.usageDecodeFailed
|
|
}
|
|
// Field names have shipped both camelCase and snake_case; read each
|
|
// alias rather than trusting one spelling.
|
|
let root = parsed as? [String: Any] ?? [:]
|
|
let snapshots = (root["quota_snapshots"] ?? root["quotaSnapshots"]) as? [String: Any]
|
|
let premium = window(label: "Premium requests",
|
|
snapshot: snapshots?["premium_interactions"] ?? snapshots?["premiumInteractions"])
|
|
let chat = window(label: "Chat", snapshot: snapshots?["chat"])
|
|
return CopilotUsage(
|
|
details: [premium, chat].compactMap { $0 },
|
|
plan: planLabel(root["copilot_plan"] ?? root["copilotPlan"]),
|
|
fetchedAt: now,
|
|
apiHost: apiHost
|
|
)
|
|
}
|
|
|
|
private static func window(label: String, snapshot: Any?) -> CopilotUsage.Window? {
|
|
guard let row = snapshot as? [String: Any],
|
|
let remaining = fraction(row["percent_remaining"] ?? row["percentRemaining"]) else { return nil }
|
|
// A plan without this quota reports entitlement 0 and 0% remaining,
|
|
// which would render as a full 100% used; unlimited windows have no
|
|
// meaningful percent either. Neither is a window to show.
|
|
if let entitlement = row["entitlement"] as? NSNumber, entitlement.doubleValue == 0 { return nil }
|
|
if let unlimited = row["unlimited"] as? Bool, unlimited { return nil }
|
|
// Round away float dust from the 1-remaining subtraction
|
|
// (1-0.7 != 0.3), mirroring the desktop decoder's toFixed(6) before
|
|
// scaling to 0..100.
|
|
let used = ((1 - remaining) * 1_000_000).rounded() / 1_000_000 * 100
|
|
return CopilotUsage.Window(label: label, usedPercent: used, resetsAt: nil)
|
|
}
|
|
|
|
/// Percent-remaining (0..100) as a clamped 0..1 fraction. Non-numeric
|
|
/// values (and booleans, which bridge to NSNumber) yield nil.
|
|
private static func fraction(_ value: Any?) -> Double? {
|
|
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
|
let fraction = number.doubleValue / 100
|
|
guard fraction.isFinite else { return nil }
|
|
return min(1, max(0, fraction))
|
|
}
|
|
|
|
/// nil for missing/blank; known tiers get display names; unknown values
|
|
/// are title-cased on _ and - boundaries (some_future_tier →
|
|
/// "Some Future Tier").
|
|
private static func planLabel(_ value: Any?) -> String? {
|
|
guard let raw = (value as? String)?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else { return nil }
|
|
let lower = raw.lowercased()
|
|
let known = [
|
|
"free": "Free", "individual": "Individual", "pro": "Pro", "business": "Business",
|
|
"enterprise": "Enterprise", "for_educators": "Educators", "for-educators": "Educators",
|
|
]
|
|
if let label = known[lower] { return label }
|
|
return lower
|
|
.components(separatedBy: CharacterSet(charactersIn: "_-"))
|
|
.filter { !$0.isEmpty }
|
|
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
// MARK: - 429 backoff
|
|
|
|
private static func usageBlockedUntil() -> Date? {
|
|
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
|
}
|
|
|
|
private static func clearUsageBlock() {
|
|
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
|
}
|
|
|
|
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
|
|
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
|
|
if let seconds = Int(value), seconds >= 0 { return seconds }
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
|
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
|
if let date = f.date(from: value) {
|
|
return max(0, Int(date.timeIntervalSinceNow))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
|
let seconds = max(retryAfterSeconds ?? 300, 60)
|
|
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
|
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
|
return until
|
|
}
|
|
|
|
static func disconnect() {
|
|
clearUsageBlock()
|
|
resetProbeCache()
|
|
}
|
|
}
|