1643 lines
63 KiB
Swift
1643 lines
63 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public struct AntigravityModelQuota: Sendable {
|
|
public let label: String
|
|
public let modelId: String
|
|
public let remainingFraction: Double?
|
|
public let resetTime: Date?
|
|
public let resetDescription: String?
|
|
|
|
public init(
|
|
label: String,
|
|
modelId: String,
|
|
remainingFraction: Double?,
|
|
resetTime: Date?,
|
|
resetDescription: String?)
|
|
{
|
|
self.label = label
|
|
self.modelId = modelId
|
|
self.remainingFraction = remainingFraction
|
|
self.resetTime = resetTime
|
|
self.resetDescription = resetDescription
|
|
}
|
|
|
|
public var remainingPercent: Double {
|
|
guard let remainingFraction else { return 0 }
|
|
return max(0, min(100, remainingFraction * 100))
|
|
}
|
|
}
|
|
|
|
private enum AntigravityModelFamily {
|
|
case claude
|
|
case gpt
|
|
case geminiPro
|
|
case geminiFlash
|
|
case unknown
|
|
}
|
|
|
|
private enum AntigravityUsagePool: Hashable {
|
|
case gemini
|
|
case claudeGPT
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .gemini: "antigravity-gemini"
|
|
case .claudeGPT: "antigravity-claude-gpt"
|
|
}
|
|
}
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .gemini: "Gemini Models"
|
|
case .claudeGPT: "Claude and GPT models"
|
|
}
|
|
}
|
|
|
|
var sortRank: Int {
|
|
switch self {
|
|
case .gemini: 0
|
|
case .claudeGPT: 1
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AntigravityModelVersion: Comparable {
|
|
let major: Int
|
|
let minor: Int
|
|
|
|
static func < (lhs: AntigravityModelVersion, rhs: AntigravityModelVersion) -> Bool {
|
|
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
|
return lhs.minor < rhs.minor
|
|
}
|
|
}
|
|
|
|
private struct AntigravityNormalizedModel {
|
|
let quota: AntigravityModelQuota
|
|
let family: AntigravityModelFamily
|
|
let selectionPriority: Int?
|
|
let isImage: Bool
|
|
let isLite: Bool
|
|
let isAutocomplete: Bool
|
|
let version: AntigravityModelVersion?
|
|
let tier: Int
|
|
}
|
|
|
|
public enum AntigravityModelQuotaSource: Sendable {
|
|
case local
|
|
case remote
|
|
}
|
|
|
|
public struct AntigravityStatusSnapshot: Sendable {
|
|
public let modelQuotas: [AntigravityModelQuota]
|
|
public let accountEmail: String?
|
|
public let accountPlan: String?
|
|
public let source: AntigravityModelQuotaSource
|
|
let quotaSummary: AntigravityQuotaSummary?
|
|
|
|
public init(
|
|
modelQuotas: [AntigravityModelQuota],
|
|
accountEmail: String?,
|
|
accountPlan: String?,
|
|
source: AntigravityModelQuotaSource = .remote)
|
|
{
|
|
self.modelQuotas = modelQuotas
|
|
self.accountEmail = accountEmail
|
|
self.accountPlan = accountPlan
|
|
self.source = source
|
|
self.quotaSummary = nil
|
|
}
|
|
|
|
init(
|
|
quotaSummary: AntigravityQuotaSummary,
|
|
accountEmail: String?,
|
|
accountPlan: String?,
|
|
source: AntigravityModelQuotaSource = .local)
|
|
{
|
|
self.modelQuotas = []
|
|
self.accountEmail = accountEmail
|
|
self.accountPlan = accountPlan
|
|
self.source = source
|
|
self.quotaSummary = quotaSummary
|
|
}
|
|
|
|
public func toUsageSnapshot() throws -> UsageSnapshot {
|
|
if let quotaSummary {
|
|
return try Self.usageSnapshot(
|
|
from: quotaSummary,
|
|
accountEmail: self.accountEmail,
|
|
accountPlan: self.accountPlan)
|
|
}
|
|
|
|
guard !self.modelQuotas.isEmpty else {
|
|
throw AntigravityStatusProbeError.parseFailed("No quota models available")
|
|
}
|
|
|
|
let normalized = Self.normalizedModels(self.modelQuotas)
|
|
let summaryCandidates = normalized.filter(Self.isSummaryCandidate)
|
|
let primaryQuota = Self.representative(for: .gemini, in: summaryCandidates)
|
|
let secondaryQuota = Self.representative(for: .claudeGPT, in: summaryCandidates)
|
|
let fallbackQuota: AntigravityModelQuota? = if primaryQuota == nil, secondaryQuota == nil {
|
|
switch self.source {
|
|
case .local:
|
|
Self.fallbackRepresentative(in: normalized.filter {
|
|
$0.family == .unknown &&
|
|
Self.isSelectableTextModel($0) &&
|
|
$0.quota.remainingFraction != nil
|
|
})
|
|
case .remote:
|
|
nil
|
|
}
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
let primary = primaryQuota.map(Self.rateWindow(for:))
|
|
let secondary = secondaryQuota.map(Self.rateWindow(for:))
|
|
let extraWindows = Self.extraRateWindows(
|
|
from: normalized,
|
|
summaryCandidates: summaryCandidates,
|
|
compactFallbackModelID: fallbackQuota?.modelId,
|
|
representedPools: Set([
|
|
primaryQuota.map { _ in AntigravityUsagePool.gemini },
|
|
secondaryQuota.map { _ in AntigravityUsagePool.claudeGPT },
|
|
].compactMap(\.self)))
|
|
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .antigravity,
|
|
accountEmail: self.accountEmail,
|
|
accountOrganization: nil,
|
|
loginMethod: self.accountPlan)
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
tertiary: nil,
|
|
extraRateWindows: extraWindows.isEmpty ? nil : extraWindows,
|
|
updatedAt: Date(),
|
|
identity: identity)
|
|
}
|
|
|
|
func withIdentity(from snapshot: AntigravityStatusSnapshot?) -> AntigravityStatusSnapshot {
|
|
guard let snapshot else { return self }
|
|
let accountEmail = snapshot.accountEmail ?? self.accountEmail
|
|
let accountPlan = snapshot.accountPlan ?? self.accountPlan
|
|
if let quotaSummary {
|
|
return AntigravityStatusSnapshot(
|
|
quotaSummary: quotaSummary,
|
|
accountEmail: accountEmail,
|
|
accountPlan: accountPlan,
|
|
source: self.source)
|
|
}
|
|
return AntigravityStatusSnapshot(
|
|
modelQuotas: self.modelQuotas,
|
|
accountEmail: accountEmail,
|
|
accountPlan: accountPlan,
|
|
source: self.source)
|
|
}
|
|
|
|
private static func usageSnapshot(
|
|
from quotaSummary: AntigravityQuotaSummary,
|
|
accountEmail: String?,
|
|
accountPlan: String?) throws -> UsageSnapshot
|
|
{
|
|
let namedWindows = Self.quotaSummaryWindows(from: quotaSummary)
|
|
guard !namedWindows.isEmpty else {
|
|
throw AntigravityStatusProbeError.parseFailed("No quota buckets available")
|
|
}
|
|
|
|
let primary = Self.quotaSummaryRepresentative(
|
|
matching: { $0.lowercased().contains("gemini") },
|
|
in: namedWindows)
|
|
let secondary = Self.quotaSummaryRepresentative(
|
|
matching: { $0.lowercased().contains("claude") || $0.lowercased().contains("gpt") },
|
|
in: namedWindows)
|
|
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .antigravity,
|
|
accountEmail: accountEmail,
|
|
accountOrganization: nil,
|
|
loginMethod: accountPlan)
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
tertiary: nil,
|
|
extraRateWindows: namedWindows,
|
|
updatedAt: Date(),
|
|
identity: identity)
|
|
}
|
|
|
|
private static func quotaSummaryRepresentative(
|
|
matching predicate: (String) -> Bool,
|
|
in windows: [NamedRateWindow]) -> RateWindow?
|
|
{
|
|
windows
|
|
.filter { $0.usageKnown && predicate($0.title) }
|
|
.max { lhs, rhs in
|
|
if lhs.window.usedPercent != rhs.window.usedPercent {
|
|
return lhs.window.usedPercent < rhs.window.usedPercent
|
|
}
|
|
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedDescending
|
|
}?
|
|
.window
|
|
}
|
|
|
|
private static func quotaSummaryWindows(from quotaSummary: AntigravityQuotaSummary) -> [NamedRateWindow] {
|
|
let sortedGroups = quotaSummary.groups.enumerated().sorted { lhs, rhs in
|
|
let lhsRank = Self.quotaGroupSortRank(lhs.element)
|
|
let rhsRank = Self.quotaGroupSortRank(rhs.element)
|
|
if lhsRank != rhsRank {
|
|
return lhsRank < rhsRank
|
|
}
|
|
return lhs.offset < rhs.offset
|
|
}.map(\.element)
|
|
|
|
return sortedGroups.flatMap { group in
|
|
let groupTitle = Self.displayTitle(forQuotaGroup: group)
|
|
let sortedBuckets = group.buckets.enumerated().sorted { lhs, rhs in
|
|
let lhsRank = Self.quotaBucketSortRank(lhs.element)
|
|
let rhsRank = Self.quotaBucketSortRank(rhs.element)
|
|
if lhsRank != rhsRank {
|
|
return lhsRank < rhsRank
|
|
}
|
|
return lhs.offset < rhs.offset
|
|
}.map(\.element)
|
|
|
|
return sortedBuckets.map { bucket in
|
|
let bucketTitle = Self.displayTitle(forQuotaBucket: bucket)
|
|
let remainingPercent = Self.remainingPercent(from: bucket.remainingFraction)
|
|
let usedPercent = remainingPercent.map { 100 - $0 } ?? 0
|
|
let window = RateWindow(
|
|
usedPercent: usedPercent,
|
|
windowMinutes: Self.windowMinutes(forQuotaBucket: bucket),
|
|
resetsAt: bucket.resetTime,
|
|
resetDescription: bucket.resetDescription)
|
|
return NamedRateWindow(
|
|
id: Self.quotaSummaryWindowID(for: bucket),
|
|
title: "\(groupTitle) \(bucketTitle)",
|
|
window: window,
|
|
usageKnown: !bucket.disabled && bucket.remainingFraction != nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func isQuotaSummaryWindowID(_ id: String) -> Bool {
|
|
id.hasPrefix(self.quotaSummaryWindowIDPrefix)
|
|
}
|
|
|
|
private static let quotaSummaryWindowIDPrefix = "antigravity-quota-summary-"
|
|
|
|
private static func quotaSummaryWindowID(for bucket: AntigravityQuotaSummaryBucket) -> String {
|
|
self.quotaSummaryWindowIDPrefix + bucket.bucketId
|
|
}
|
|
|
|
private static func remainingPercent(from remainingFraction: Double?) -> Double? {
|
|
guard let remainingFraction else { return nil }
|
|
return max(0, min(100, remainingFraction * 100))
|
|
}
|
|
|
|
private static func displayTitle(forQuotaGroup group: AntigravityQuotaSummaryGroup) -> String {
|
|
let title = group.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let lowercasedTitle = title.lowercased()
|
|
if lowercasedTitle.contains("gemini") {
|
|
return "Gemini"
|
|
}
|
|
if lowercasedTitle.contains("claude") || lowercasedTitle.contains("gpt") {
|
|
return "Claude/GPT"
|
|
}
|
|
return title.isEmpty ? "Quota" : title
|
|
}
|
|
|
|
private static func displayTitle(forQuotaBucket bucket: AntigravityQuotaSummaryBucket) -> String {
|
|
switch self.quotaBucketKind(for: bucket) {
|
|
case .session:
|
|
"5-hour"
|
|
case .weekly:
|
|
"weekly"
|
|
case .other:
|
|
bucket.displayName
|
|
}
|
|
}
|
|
|
|
private static func windowMinutes(forQuotaBucket bucket: AntigravityQuotaSummaryBucket) -> Int? {
|
|
switch self.quotaBucketKind(for: bucket) {
|
|
case .session:
|
|
300
|
|
case .weekly:
|
|
10080
|
|
case .other:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private enum QuotaBucketKind {
|
|
case session
|
|
case weekly
|
|
case other
|
|
}
|
|
|
|
private static func quotaGroupSortRank(_ group: AntigravityQuotaSummaryGroup) -> Int {
|
|
let title = group.displayName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if title.contains("gemini") {
|
|
return 0
|
|
}
|
|
if title.contains("claude") || title.contains("gpt") {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
private static func quotaBucketSortRank(_ bucket: AntigravityQuotaSummaryBucket) -> Int {
|
|
switch self.quotaBucketKind(for: bucket) {
|
|
case .session:
|
|
0
|
|
case .weekly:
|
|
1
|
|
case .other:
|
|
2
|
|
}
|
|
}
|
|
|
|
private static func quotaBucketKind(for bucket: AntigravityQuotaSummaryBucket) -> QuotaBucketKind {
|
|
let combined = "\(bucket.bucketId) \(bucket.displayName)".lowercased()
|
|
if combined.contains("5h") || combined.contains("5-hour") || combined.contains("five hour") {
|
|
return .session
|
|
}
|
|
if combined.contains("weekly") {
|
|
return .weekly
|
|
}
|
|
return .other
|
|
}
|
|
|
|
static func quotaDisplayLabel(_ quota: AntigravityModelQuota) -> String {
|
|
let trimmed = quota.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.isEmpty || trimmed == quota.modelId else { return quota.label }
|
|
return Self.humanizedModelID(quota.modelId)
|
|
}
|
|
|
|
static func humanizedModelID(_ modelId: String) -> String {
|
|
let parts = modelId.split(separator: "-").map(String.init)
|
|
var words: [String] = []
|
|
var index = 0
|
|
|
|
while index < parts.count {
|
|
let part = parts[index]
|
|
if Self.isSingleDigit(part), index + 1 < parts.count, Self.isSingleDigit(parts[index + 1]) {
|
|
var versionParts = [part]
|
|
index += 1
|
|
while index < parts.count, Self.isSingleDigit(parts[index]) {
|
|
versionParts.append(parts[index])
|
|
index += 1
|
|
}
|
|
words.append(versionParts.joined(separator: "."))
|
|
continue
|
|
}
|
|
|
|
if part.allSatisfy({ $0.isNumber || $0 == "." }) {
|
|
words.append(part)
|
|
} else if Self.modelLabelAcronyms.contains(part.lowercased()) {
|
|
words.append(part.uppercased())
|
|
} else {
|
|
words.append(part.prefix(1).uppercased() + part.dropFirst())
|
|
}
|
|
index += 1
|
|
}
|
|
|
|
return words.joined(separator: " ")
|
|
}
|
|
|
|
private static let modelLabelAcronyms: Set<String> = ["ai", "api", "gpt", "oss"]
|
|
|
|
private static func isSingleDigit(_ value: String) -> Bool {
|
|
value.count == 1 && value.allSatisfy(\.isNumber)
|
|
}
|
|
|
|
private static func rateWindow(for quota: AntigravityModelQuota) -> RateWindow {
|
|
RateWindow(
|
|
usedPercent: 100 - quota.remainingPercent,
|
|
windowMinutes: nil,
|
|
resetsAt: quota.resetTime,
|
|
resetDescription: quota.resetDescription)
|
|
}
|
|
|
|
private static func modelOrderPrecedes(
|
|
_ lhs: AntigravityNormalizedModel,
|
|
_ rhs: AntigravityNormalizedModel) -> Bool
|
|
{
|
|
// 1. Family rank: claude=0, geminiPro=1, geminiFlash=2, unknown=3
|
|
let lhsFamilyRank = Self.familyRank(lhs.family)
|
|
let rhsFamilyRank = Self.familyRank(rhs.family)
|
|
if lhsFamilyRank != rhsFamilyRank {
|
|
return lhsFamilyRank < rhsFamilyRank
|
|
}
|
|
|
|
// 2. Version descending (newer first); nil version sorts after non-nil
|
|
switch (lhs.version, rhs.version) {
|
|
case let (.some(lv), .some(rv)):
|
|
if lv != rv {
|
|
return lv > rv
|
|
}
|
|
case (.some, .none):
|
|
return true
|
|
case (.none, .some):
|
|
return false
|
|
case (.none, .none):
|
|
break
|
|
}
|
|
|
|
// 3. Tier ascending: High(0) < Medium(1) < Low(2)
|
|
if lhs.tier != rhs.tier {
|
|
return lhs.tier < rhs.tier
|
|
}
|
|
|
|
// 4. Label tiebreaker
|
|
return lhs.quota.label.localizedCaseInsensitiveCompare(rhs.quota.label) == .orderedAscending
|
|
}
|
|
|
|
private static func familyRank(_ family: AntigravityModelFamily) -> Int {
|
|
switch family {
|
|
case .claude: 0
|
|
case .gpt: 1
|
|
case .geminiPro: 2
|
|
case .geminiFlash: 3
|
|
case .unknown: 4
|
|
}
|
|
}
|
|
|
|
private static func isSummaryCandidate(_ model: AntigravityNormalizedModel) -> Bool {
|
|
self.usagePool(for: model) != nil && self.isSelectableTextModel(model)
|
|
}
|
|
|
|
private static func isSelectableTextModel(_ model: AntigravityNormalizedModel) -> Bool {
|
|
!model.isLite && !model.isAutocomplete && !model.isImage
|
|
}
|
|
|
|
private static func normalizedModels(_ models: [AntigravityModelQuota]) -> [AntigravityNormalizedModel] {
|
|
models.map { self.normalizeModel($0) }
|
|
}
|
|
|
|
private static func normalizeModel(_ quota: AntigravityModelQuota) -> AntigravityNormalizedModel {
|
|
let modelId = quota.modelId.lowercased()
|
|
let label = quota.label.lowercased()
|
|
let family = Self.family(forModelID: modelId, label: label)
|
|
|
|
let isLite = modelId.contains("lite") || label.contains("lite")
|
|
let isAutocomplete = modelId.contains("autocomplete") || label.contains("autocomplete") || modelId
|
|
.hasPrefix("tab_")
|
|
let isImage = modelId.contains("image") || label.contains("image")
|
|
let isSelectableTextModel = !isLite && !isAutocomplete && !isImage
|
|
let isLowPriorityGeminiPro = modelId.contains("pro-low")
|
|
|| (label.contains("pro") && label.contains("low"))
|
|
|
|
let selectionPriority: Int? = switch family {
|
|
case .claude, .gpt:
|
|
0
|
|
case .geminiPro:
|
|
if isLowPriorityGeminiPro, isSelectableTextModel {
|
|
0
|
|
} else if isSelectableTextModel {
|
|
1
|
|
} else {
|
|
nil
|
|
}
|
|
case .geminiFlash:
|
|
isSelectableTextModel ? 0 : nil
|
|
case .unknown:
|
|
nil
|
|
}
|
|
|
|
let version = Self.parseVersion(from: label)
|
|
let tier = Self.parseTier(from: label, modelId: modelId)
|
|
|
|
return AntigravityNormalizedModel(
|
|
quota: quota,
|
|
family: family,
|
|
selectionPriority: selectionPriority,
|
|
isImage: isImage,
|
|
isLite: isLite,
|
|
isAutocomplete: isAutocomplete,
|
|
version: version,
|
|
tier: tier)
|
|
}
|
|
|
|
private static func parseVersion(from label: String) -> AntigravityModelVersion? {
|
|
// Accept either "." or "-" between major and minor so a raw model id used as the
|
|
// label when displayName is missing (e.g. "gemini-3-1-pro-low") still parses 3.1.
|
|
guard let regex = try? NSRegularExpression(pattern: #"(\d+)(?:[.\-](\d+))?"#) else { return nil }
|
|
let nsLabel = label as NSString
|
|
let range = NSRange(location: 0, length: nsLabel.length)
|
|
guard let match = regex.firstMatch(in: label, options: [], range: range) else { return nil }
|
|
let majorRange = Range(match.range(at: 1), in: label)
|
|
guard let majorRange, let major = Int(label[majorRange]) else { return nil }
|
|
let minor: Int = if match.range(at: 2).location != NSNotFound,
|
|
let minorRange = Range(match.range(at: 2), in: label),
|
|
let parsed = Int(label[minorRange])
|
|
{
|
|
parsed
|
|
} else {
|
|
0
|
|
}
|
|
return AntigravityModelVersion(major: major, minor: minor)
|
|
}
|
|
|
|
private static func parseTier(from label: String, modelId: String) -> Int {
|
|
let combined = label + " " + modelId
|
|
if combined.contains("high") { return 0 }
|
|
if combined.contains("medium") { return 1 }
|
|
if combined.contains("low") { return 2 }
|
|
return 1
|
|
}
|
|
|
|
private static func representative(
|
|
for pool: AntigravityUsagePool,
|
|
in models: [AntigravityNormalizedModel]) -> AntigravityModelQuota?
|
|
{
|
|
let candidates = models.filter {
|
|
Self.usagePool(for: $0) == pool && $0.quota.remainingFraction != nil
|
|
}
|
|
guard !candidates.isEmpty else { return nil }
|
|
return candidates.min { lhs, rhs in
|
|
if lhs.quota.remainingPercent != rhs.quota.remainingPercent {
|
|
return lhs.quota.remainingPercent < rhs.quota.remainingPercent
|
|
}
|
|
switch (lhs.quota.resetTime, rhs.quota.resetTime) {
|
|
case let (.some(left), .some(right)) where left != right:
|
|
return left < right
|
|
case (.some, .none):
|
|
return true
|
|
case (.none, .some):
|
|
return false
|
|
default:
|
|
return lhs.quota.label.localizedCaseInsensitiveCompare(rhs.quota.label) == .orderedAscending
|
|
}
|
|
}?.quota
|
|
}
|
|
|
|
private static func fallbackRepresentative(in models: [AntigravityNormalizedModel]) -> AntigravityModelQuota? {
|
|
models.min { lhs, rhs in
|
|
if lhs.quota.remainingPercent != rhs.quota.remainingPercent {
|
|
return lhs.quota.remainingPercent < rhs.quota.remainingPercent
|
|
}
|
|
return lhs.quota.label.localizedCaseInsensitiveCompare(rhs.quota.label) == .orderedAscending
|
|
}?.quota
|
|
}
|
|
|
|
private static func extraRateWindows(
|
|
from models: [AntigravityNormalizedModel],
|
|
summaryCandidates: [AntigravityNormalizedModel],
|
|
compactFallbackModelID: String?,
|
|
representedPools: Set<AntigravityUsagePool>) -> [NamedRateWindow]
|
|
{
|
|
let resetOnlyPoolWindows = [AntigravityUsagePool.gemini, .claudeGPT].compactMap { pool -> NamedRateWindow? in
|
|
guard !representedPools.contains(pool) else { return nil }
|
|
let candidates = summaryCandidates.filter { Self.usagePool(for: $0) == pool }
|
|
guard let resetOnly = candidates.first(where: { model in
|
|
model.quota.remainingFraction == nil &&
|
|
(model.quota.resetTime != nil || model.quota.resetDescription != nil)
|
|
}) else {
|
|
return nil
|
|
}
|
|
return NamedRateWindow(
|
|
id: pool.id,
|
|
title: pool.title,
|
|
window: Self.rateWindow(for: resetOnly.quota),
|
|
usageKnown: false)
|
|
}
|
|
|
|
let distinctWindows = models
|
|
.filter {
|
|
$0.quota.modelId == compactFallbackModelID || Self.shouldShowDistinctExtraWindow($0)
|
|
}
|
|
.sorted(by: Self.modelOrderPrecedes)
|
|
.map { m in
|
|
NamedRateWindow(
|
|
id: m.quota.modelId == compactFallbackModelID
|
|
? Self.compactFallbackWindowID(modelID: m.quota.modelId)
|
|
: m.quota.modelId,
|
|
title: Self.quotaDisplayLabel(m.quota),
|
|
window: Self.rateWindow(for: m.quota),
|
|
usageKnown: m.quota.remainingFraction != nil)
|
|
}
|
|
|
|
return resetOnlyPoolWindows.sorted { lhs, rhs in
|
|
guard let lhsPool = Self.pool(forExtraWindowID: lhs.id),
|
|
let rhsPool = Self.pool(forExtraWindowID: rhs.id)
|
|
else {
|
|
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
|
|
}
|
|
return lhsPool.sortRank < rhsPool.sortRank
|
|
} + distinctWindows
|
|
}
|
|
|
|
private static func compactFallbackWindowID(modelID: String) -> String {
|
|
"antigravity-compact-fallback-\(modelID)"
|
|
}
|
|
|
|
private static func shouldShowDistinctExtraWindow(_ model: AntigravityNormalizedModel) -> Bool {
|
|
guard !self.isSummaryCandidate(model) else { return false }
|
|
if model.quota.remainingFraction == nil {
|
|
return model.quota.resetTime != nil || model.quota.resetDescription != nil
|
|
}
|
|
return model.quota.remainingPercent < 99.9
|
|
}
|
|
|
|
private static func pool(forExtraWindowID id: String) -> AntigravityUsagePool? {
|
|
switch id {
|
|
case AntigravityUsagePool.gemini.id: .gemini
|
|
case AntigravityUsagePool.claudeGPT.id: .claudeGPT
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
private static func usagePool(for model: AntigravityNormalizedModel) -> AntigravityUsagePool? {
|
|
switch model.family {
|
|
case .geminiPro, .geminiFlash:
|
|
.gemini
|
|
case .claude, .gpt:
|
|
.claudeGPT
|
|
case .unknown:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private static func family(forModelID modelId: String, label: String) -> AntigravityModelFamily {
|
|
let modelIDFamily = Self.family(from: modelId)
|
|
if modelIDFamily != .unknown {
|
|
return modelIDFamily
|
|
}
|
|
return Self.family(from: label)
|
|
}
|
|
|
|
private static func family(from text: String) -> AntigravityModelFamily {
|
|
if text.contains("claude") {
|
|
return .claude
|
|
}
|
|
if text.contains("gpt") || text.contains("openai") {
|
|
return .gpt
|
|
}
|
|
if text.contains("gemini"), text.contains("pro") {
|
|
return .geminiPro
|
|
}
|
|
if text.contains("gemini"), text.contains("flash") {
|
|
return .geminiFlash
|
|
}
|
|
return .unknown
|
|
}
|
|
}
|
|
|
|
public struct AntigravityPlanInfoSummary: Sendable, Codable, Equatable {
|
|
public let planName: String?
|
|
public let planDisplayName: String?
|
|
public let displayName: String?
|
|
public let productName: String?
|
|
public let planShortName: String?
|
|
}
|
|
|
|
public enum AntigravityStatusProbeError: LocalizedError, Sendable, Equatable {
|
|
case notRunning
|
|
case missingCSRFToken
|
|
case portDetectionFailed(String)
|
|
case apiError(String)
|
|
case parseFailed(String)
|
|
case timedOut
|
|
case authenticationRequired
|
|
case accountMismatch(expected: String?, found: String?)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .notRunning:
|
|
"Antigravity language server not detected. Launch Antigravity and retry."
|
|
case .missingCSRFToken:
|
|
"Antigravity CSRF token not found. Restart Antigravity and retry."
|
|
case let .portDetectionFailed(message):
|
|
Self.portDetectionDescription(message)
|
|
case let .apiError(message):
|
|
Self.apiErrorDescription(message)
|
|
case let .parseFailed(message):
|
|
"Could not parse Antigravity quota: \(message)"
|
|
case .timedOut:
|
|
"Antigravity quota request timed out."
|
|
case .authenticationRequired:
|
|
"Antigravity CLI is signed out. Run agy in a terminal to sign in, then retry."
|
|
case let .accountMismatch(expected, found):
|
|
Self.accountMismatchDescription(expected: expected, found: found)
|
|
}
|
|
}
|
|
|
|
private static func accountMismatchDescription(expected: String?, found: String?) -> String {
|
|
let selected = expected ?? "the selected account"
|
|
if let found {
|
|
return "Antigravity local session is signed in as \(found), not \(selected); "
|
|
+ "using the selected account's OAuth data instead."
|
|
}
|
|
return "Antigravity local session did not report an account matching \(selected); "
|
|
+ "using the selected account's OAuth data instead."
|
|
}
|
|
|
|
private static func portDetectionDescription(_ message: String) -> String {
|
|
switch message {
|
|
case "lsof not available":
|
|
"Antigravity port detection needs lsof. Install it, then retry."
|
|
case "no listening ports found":
|
|
"Antigravity is running but not exposing ports yet. Try again in a few seconds."
|
|
default:
|
|
"Antigravity port detection failed: \(message)"
|
|
}
|
|
}
|
|
|
|
private static func apiErrorDescription(_ message: String) -> String {
|
|
if message.contains("HTTP 401") || message.contains("HTTP 403") {
|
|
return "Antigravity session expired. Restart Antigravity and retry."
|
|
}
|
|
return "Antigravity API error: \(message)"
|
|
}
|
|
}
|
|
|
|
public struct AntigravityStatusProbe: Sendable {
|
|
/// Which local Antigravity processes the probe may attach to.
|
|
public enum ProcessScope: Sendable {
|
|
/// Match Antigravity app, Antigravity IDE, and the `agy` CLI language server.
|
|
case ideAndCLI
|
|
/// Match only the Antigravity 2.0 app language server.
|
|
case appOnly
|
|
/// Match only the Antigravity IDE extension language server.
|
|
case ideOnly
|
|
}
|
|
|
|
public var timeout: TimeInterval = 8.0
|
|
public var processScope: ProcessScope = .ideAndCLI
|
|
|
|
private static let getUserStatusPath = "/exa.language_server_pb.LanguageServerService/GetUserStatus"
|
|
private static let commandModelConfigPath =
|
|
"/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs"
|
|
private static let quotaSummaryPath =
|
|
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"
|
|
private static let unleashPath = "/exa.language_server_pb.LanguageServerService/GetUnleashData"
|
|
private static let log = CodexBarLog.logger(LogCategories.antigravity)
|
|
|
|
public init(timeout: TimeInterval = 8.0, processScope: ProcessScope = .ideAndCLI) {
|
|
self.timeout = timeout
|
|
self.processScope = processScope
|
|
}
|
|
|
|
public func fetch(matchingAccountEmail expectedAccountEmail: String? = nil) async throws
|
|
-> AntigravityStatusSnapshot
|
|
{
|
|
let deadline = Date().addingTimeInterval(self.timeout)
|
|
let processInfos = try await Self.detectProcessInfos(timeout: self.timeout, scope: self.processScope)
|
|
let result = try await Self.fetchProcessSnapshots(processInfos: processInfos) { processInfo in
|
|
try await Self.fetch(
|
|
processInfo: processInfo,
|
|
timeout: self.timeout,
|
|
deadline: deadline)
|
|
}
|
|
|
|
if let bestSnapshot = Self.preferredLocalSnapshot(
|
|
result.snapshots,
|
|
matchingAccountEmail: expectedAccountEmail)
|
|
{
|
|
return bestSnapshot
|
|
}
|
|
throw result.lastError ?? AntigravityStatusProbeError.notRunning
|
|
}
|
|
|
|
public func fetchPlanInfoSummary() async throws -> AntigravityPlanInfoSummary? {
|
|
let processInfo = try await Self.detectProcessInfo(timeout: self.timeout)
|
|
let ports = try await Self.listeningPorts(pid: processInfo.pid, timeout: self.timeout)
|
|
let endpoint = try await Self.resolveWorkingEndpoint(
|
|
candidateEndpoints: Self.connectionCandidates(
|
|
listeningPorts: ports,
|
|
languageServerCSRFToken: processInfo.csrfToken,
|
|
extensionServerPort: processInfo.extensionPort,
|
|
extensionServerCSRFToken: processInfo.extensionServerCSRFToken),
|
|
timeout: self.timeout)
|
|
return try await Self.makeParsedRequest(
|
|
payload: RequestPayload(
|
|
path: Self.getUserStatusPath,
|
|
body: Self.defaultRequestBody()),
|
|
context: RequestContext(
|
|
endpoints: Self.requestEndpoints(
|
|
resolvedEndpoint: endpoint,
|
|
listeningPorts: ports,
|
|
languageServerCSRFToken: processInfo.csrfToken,
|
|
extensionServerPort: processInfo.extensionPort,
|
|
extensionServerCSRFToken: processInfo.extensionServerCSRFToken),
|
|
timeout: self.timeout),
|
|
parse: Self.parsePlanInfoSummary)
|
|
}
|
|
|
|
static func localSnapshotScore(_ snapshot: AntigravityStatusSnapshot) -> Int {
|
|
var score = 0
|
|
if let quotaSummary = snapshot.quotaSummary {
|
|
let buckets = quotaSummary.groups.flatMap(\.buckets)
|
|
let knownBuckets = buckets.count(where: { !$0.disabled && $0.remainingFraction != nil })
|
|
score += 1000
|
|
score += quotaSummary.groups.count * 10
|
|
score += buckets.count
|
|
score += knownBuckets * 20
|
|
} else {
|
|
let knownRows = snapshot.modelQuotas.count(where: { $0.remainingFraction != nil })
|
|
score += snapshot.modelQuotas.count
|
|
score += knownRows * 10
|
|
}
|
|
if snapshot.accountEmail != nil {
|
|
score += 2
|
|
}
|
|
if snapshot.accountPlan != nil {
|
|
score += 1
|
|
}
|
|
return score
|
|
}
|
|
|
|
public static func isRunning(timeout: TimeInterval = 4.0) async -> Bool {
|
|
await (try? self.detectProcessInfo(timeout: timeout)) != nil
|
|
}
|
|
|
|
public static func detectVersion(timeout: TimeInterval = 4.0) async -> String? {
|
|
let running = await Self.isRunning(timeout: timeout)
|
|
return running ? "running" : nil
|
|
}
|
|
|
|
// MARK: - CLI Local Fetch
|
|
|
|
/// Fetch usage data from a known set of local ports (discovered via
|
|
/// ``AntigravityCLISession``'s ``pid``), without requiring a running
|
|
/// ``language_server`` process or CSRF token.
|
|
///
|
|
/// The ``agy`` CLI exposes the same ``GetUserStatus`` gRPC-web endpoint as
|
|
/// the desktop ``language_server``. Unlike the desktop endpoint, it does
|
|
/// not require a CSRF token header.
|
|
public func fetchFromPorts(_ ports: [Int], deadline: Date? = nil) async throws -> AntigravityStatusSnapshot {
|
|
guard !ports.isEmpty else {
|
|
throw AntigravityStatusProbeError.portDetectionFailed("no listening ports found")
|
|
}
|
|
let endpoints = Self.cliEndpoints(ports: ports)
|
|
let context = RequestContext(endpoints: endpoints, timeout: self.timeout, deadline: deadline)
|
|
return try await Self.fetchSnapshot(context: context)
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
public static func parseUserStatusResponse(_ data: Data) throws -> AntigravityStatusSnapshot {
|
|
let decoder = JSONDecoder()
|
|
let response = try decoder.decode(UserStatusResponse.self, from: data)
|
|
if let invalid = Self.invalidCode(response.code) {
|
|
throw AntigravityStatusProbeError.apiError(invalid)
|
|
}
|
|
guard let userStatus = response.userStatus else {
|
|
throw AntigravityStatusProbeError.parseFailed("Missing userStatus")
|
|
}
|
|
|
|
let modelConfigs = userStatus.cascadeModelConfigData?.clientModelConfigs ?? []
|
|
let models = modelConfigs.compactMap(Self.quotaFromConfig(_:))
|
|
let email = userStatus.email
|
|
// Prefer userTier.name (actual subscription tier) over planInfo (shows "Pro" for Ultra users)
|
|
let planName = userStatus.userTier?.preferredName ?? userStatus.planStatus?.planInfo?.preferredName
|
|
|
|
return AntigravityStatusSnapshot(
|
|
modelQuotas: models,
|
|
accountEmail: email,
|
|
accountPlan: planName,
|
|
source: .local)
|
|
}
|
|
|
|
static func parsePlanInfoSummary(_ data: Data) throws -> AntigravityPlanInfoSummary? {
|
|
let decoder = JSONDecoder()
|
|
let response = try decoder.decode(UserStatusResponse.self, from: data)
|
|
if let invalid = Self.invalidCode(response.code) {
|
|
throw AntigravityStatusProbeError.apiError(invalid)
|
|
}
|
|
guard let userStatus = response.userStatus else {
|
|
throw AntigravityStatusProbeError.parseFailed("Missing userStatus")
|
|
}
|
|
guard let planInfo = userStatus.planStatus?.planInfo else { return nil }
|
|
return AntigravityPlanInfoSummary(
|
|
planName: planInfo.planName,
|
|
planDisplayName: planInfo.planDisplayName,
|
|
displayName: planInfo.displayName,
|
|
productName: planInfo.productName,
|
|
planShortName: planInfo.planShortName)
|
|
}
|
|
|
|
static func parseCommandModelResponse(_ data: Data) throws -> AntigravityStatusSnapshot {
|
|
let decoder = JSONDecoder()
|
|
let response = try decoder.decode(CommandModelConfigResponse.self, from: data)
|
|
if let invalid = Self.invalidCode(response.code) {
|
|
throw AntigravityStatusProbeError.apiError(invalid)
|
|
}
|
|
let modelConfigs = response.clientModelConfigs ?? []
|
|
let models = modelConfigs.compactMap(Self.quotaFromConfig(_:))
|
|
return AntigravityStatusSnapshot(modelQuotas: models, accountEmail: nil, accountPlan: nil, source: .local)
|
|
}
|
|
|
|
private static func quotaFromConfig(_ config: ModelConfig) -> AntigravityModelQuota? {
|
|
guard let quota = config.quotaInfo else { return nil }
|
|
let reset = quota.resetTime.flatMap { Self.parseDate($0) }
|
|
return AntigravityModelQuota(
|
|
label: config.label,
|
|
modelId: config.modelOrAlias.model,
|
|
remainingFraction: quota.remainingFraction,
|
|
resetTime: reset,
|
|
resetDescription: nil)
|
|
}
|
|
|
|
static func invalidCode(_ code: CodeValue?) -> String? {
|
|
guard let code else { return nil }
|
|
if code.isOK { return nil }
|
|
return "\(code.rawValue)"
|
|
}
|
|
|
|
static func parseDate(_ value: String) -> Date? {
|
|
if let date = ISO8601DateFormatter().date(from: value) {
|
|
return date
|
|
}
|
|
if let seconds = Double(value) {
|
|
return Date(timeIntervalSince1970: seconds)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Port detection
|
|
|
|
struct ProcessInfoResult {
|
|
let pid: Int
|
|
let extensionPort: Int?
|
|
let extensionServerCSRFToken: String?
|
|
let csrfToken: String
|
|
let commandLine: String
|
|
}
|
|
|
|
struct AntigravityConnectionEndpoint: Equatable {
|
|
enum Source: String {
|
|
case languageServer = "language-server"
|
|
case extensionServer = "extension-server"
|
|
case cliHTTPS = "cli-https"
|
|
}
|
|
|
|
let scheme: String
|
|
let port: Int
|
|
let csrfToken: String
|
|
let source: Source
|
|
/// Whether this endpoint needs a CSRF token header.
|
|
/// The CLI HTTPS endpoint (``Source/cliHTTPS``) speaks the same HTTP API
|
|
/// but does not require a CSRF token.
|
|
var requiresCSRFToken: Bool {
|
|
switch self.source {
|
|
case .languageServer, .extensionServer: true
|
|
case .cliHTTPS: false
|
|
}
|
|
}
|
|
|
|
func matchesRequestTarget(_ other: Self) -> Bool {
|
|
self.scheme == other.scheme && self.port == other.port && self.csrfToken == other.csrfToken
|
|
}
|
|
}
|
|
|
|
private static func detectProcessInfo(
|
|
timeout: TimeInterval,
|
|
scope: ProcessScope = .ideAndCLI) async throws -> ProcessInfoResult
|
|
{
|
|
let processInfos = try await self.detectProcessInfos(timeout: timeout, scope: scope)
|
|
guard let first = processInfos.first else {
|
|
throw AntigravityStatusProbeError.notRunning
|
|
}
|
|
return first
|
|
}
|
|
|
|
/// Internal (not private): called by AntigravityCLIHTTPSFetchStrategy
|
|
/// .liveWarmAgyDependencies to discover an already-running `agy` for reuse.
|
|
static func detectProcessInfos(
|
|
timeout: TimeInterval,
|
|
scope: ProcessScope = .ideAndCLI) async throws -> [ProcessInfoResult]
|
|
{
|
|
let env = ProcessInfo.processInfo.environment
|
|
let result = try await SubprocessRunner.run(
|
|
binary: "/bin/ps",
|
|
arguments: ["-ax", "-o", "pid=,command="],
|
|
environment: env,
|
|
timeout: timeout,
|
|
label: "antigravity-ps")
|
|
|
|
return try Self.processInfos(fromProcessListOutput: result.stdout, scope: scope)
|
|
}
|
|
|
|
static func processInfo(
|
|
fromProcessListOutput output: String,
|
|
scope: ProcessScope = .ideAndCLI) throws -> ProcessInfoResult
|
|
{
|
|
let processInfos = try self.processInfos(fromProcessListOutput: output, scope: scope)
|
|
guard let first = processInfos.first else {
|
|
throw AntigravityStatusProbeError.notRunning
|
|
}
|
|
return first
|
|
}
|
|
|
|
static func processInfos(
|
|
fromProcessListOutput output: String,
|
|
scope: ProcessScope = .ideAndCLI) throws -> [ProcessInfoResult]
|
|
{
|
|
let lines = output.split(separator: "\n")
|
|
var sawTokenlessIDE = false
|
|
var results: [ProcessInfoResult] = []
|
|
for line in lines {
|
|
let text = String(line)
|
|
guard let match = Self.matchProcessLine(text) else { continue }
|
|
guard let kind = Self.antigravityProcessKind(match.command) else { continue }
|
|
if !Self.processKind(kind, matches: scope) { continue }
|
|
// The IDE language server authenticates local requests with a
|
|
// `--csrf_token` and must keep requiring it: skip a tokenless IDE
|
|
// or app match so a later valid server can still be found (and surface
|
|
// `missingCSRFToken` if none is). The CLI's language server exposes
|
|
// no token flag and needs none, so an empty token is allowed there.
|
|
guard let token = Self.resolvedCSRFToken(forKind: kind, command: match.command) else {
|
|
sawTokenlessIDE = true
|
|
continue
|
|
}
|
|
let port = Self.extractPort("--extension_server_port", from: match.command)
|
|
let extensionServerCSRFToken = Self.extractFlag("--extension_server_csrf_token", from: match.command)
|
|
results.append(ProcessInfoResult(
|
|
pid: match.pid,
|
|
extensionPort: port,
|
|
extensionServerCSRFToken: extensionServerCSRFToken,
|
|
csrfToken: token,
|
|
commandLine: match.command))
|
|
}
|
|
|
|
if !results.isEmpty {
|
|
return results
|
|
}
|
|
if sawTokenlessIDE {
|
|
throw AntigravityStatusProbeError.missingCSRFToken
|
|
}
|
|
throw AntigravityStatusProbeError.notRunning
|
|
}
|
|
|
|
private struct ProcessLineMatch {
|
|
let pid: Int
|
|
let command: String
|
|
}
|
|
|
|
private static func matchProcessLine(_ line: String) -> ProcessLineMatch? {
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
|
guard parts.count == 2, let pid = Int(parts[0]) else { return nil }
|
|
return ProcessLineMatch(pid: pid, command: String(parts[1]))
|
|
}
|
|
|
|
enum AntigravityProcessKind: Equatable {
|
|
/// Antigravity 2.0 app language server. Requires a `--csrf_token`.
|
|
case app
|
|
/// Antigravity IDE extension language server. Requires a `--csrf_token`.
|
|
case ide
|
|
/// CLI language server (`agy` / `antigravity-cli`). Needs no CSRF token.
|
|
case cli
|
|
}
|
|
|
|
static func isAntigravityLanguageServerCommandLine(_ command: String) -> Bool {
|
|
self.antigravityProcessKind(command) != nil
|
|
}
|
|
|
|
/// Classify a process command line as the Antigravity app language server,
|
|
/// the Antigravity IDE language server, the Antigravity CLI language server,
|
|
/// or neither. Desktop language servers take precedence so their CSRF-token
|
|
/// requirement is preserved.
|
|
static func antigravityProcessKind(_ command: String) -> AntigravityProcessKind? {
|
|
let lower = command.lowercased()
|
|
if Self.isLanguageServerCommandLine(lower), Self.isAntigravityCommandLine(lower) {
|
|
return Self.isAntigravityIDECommandLine(lower) ? .ide : .app
|
|
}
|
|
if Self.isAntigravityCLICommandLine(lower) {
|
|
return .cli
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func processKind(_ kind: AntigravityProcessKind, matches scope: ProcessScope) -> Bool {
|
|
switch scope {
|
|
case .ideAndCLI:
|
|
true
|
|
case .appOnly:
|
|
kind == .app
|
|
case .ideOnly:
|
|
kind == .ide
|
|
}
|
|
}
|
|
|
|
/// Resolve the CSRF token to use for a matched process, or `nil` when the
|
|
/// match must be skipped. IDE matches keep requiring `--csrf_token`
|
|
/// (tokenless IDE matches are skipped). CLI matches accept an empty token
|
|
/// because the CLI's language server requires none.
|
|
static func resolvedCSRFToken(forKind kind: AntigravityProcessKind, command: String) -> String? {
|
|
if let token = extractFlag("--csrf_token", from: command) {
|
|
return token
|
|
}
|
|
switch kind {
|
|
case .app, .ide: return nil
|
|
case .cli: return ""
|
|
}
|
|
}
|
|
|
|
private static func isLanguageServerCommandLine(_ lowerCommand: String) -> Bool {
|
|
let pattern = #"(^|[/\\])language(?:_|-)server(?:[_-][a-z0-9]+)*(?:\.exe)?(\s|$)"#
|
|
return lowerCommand.range(of: pattern, options: .regularExpression) != nil
|
|
}
|
|
|
|
/// The Antigravity CLI (`agy` / `antigravity-cli`) hosts the same language
|
|
/// server locally as the IDE, but launches it without a `--csrf_token` flag
|
|
/// and under a different process name. Match it so usage can be probed when
|
|
/// only the CLI is running.
|
|
private static func isAntigravityCLICommandLine(_ lowerCommand: String) -> Bool {
|
|
let cliPathPattern = #"(^|[/\\])(antigravity-cli|antigravity_cli)([\s/\\]|$)"#
|
|
if lowerCommand.range(of: cliPathPattern, options: .regularExpression) != nil {
|
|
return true
|
|
}
|
|
let agyPattern = #"(^|[/\\])agy(\s|$)"#
|
|
return lowerCommand.range(of: agyPattern, options: .regularExpression) != nil
|
|
}
|
|
|
|
private static func isAntigravityCommandLine(_ command: String) -> Bool {
|
|
if command.contains("--app_data_dir") && command.contains("antigravity") { return true }
|
|
if command.contains("antigravity.app/") || command.contains("antigravity.app\\") { return true }
|
|
if command.contains("antigravity ide.app/") || command.contains("antigravity ide.app\\") { return true }
|
|
if command.contains("/antigravity/") || command.contains("\\antigravity\\") { return true }
|
|
return false
|
|
}
|
|
|
|
private static func isAntigravityIDECommandLine(_ lowerCommand: String) -> Bool {
|
|
[
|
|
"antigravity ide.app/",
|
|
"antigravity ide.app\\",
|
|
"--app_data_dir antigravity-ide",
|
|
"--app_data_dir=antigravity-ide",
|
|
"/extensions/antigravity/bin/language_server",
|
|
"\\extensions\\antigravity\\bin\\language_server",
|
|
].contains { lowerCommand.contains($0) }
|
|
}
|
|
|
|
private static func extractFlag(_ flag: String, from command: String) -> String? {
|
|
let pattern = "\(NSRegularExpression.escapedPattern(for: flag))[=\\s]+([^\\s]+)"
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return nil }
|
|
let range = NSRange(command.startIndex..<command.endIndex, in: command)
|
|
guard let match = regex.firstMatch(in: command, options: [], range: range),
|
|
let tokenRange = Range(match.range(at: 1), in: command) else { return nil }
|
|
return String(command[tokenRange])
|
|
}
|
|
|
|
private static func extractPort(_ flag: String, from command: String) -> Int? {
|
|
guard let raw = extractFlag(flag, from: command) else { return nil }
|
|
return Int(raw)
|
|
}
|
|
|
|
static func connectionCandidates(
|
|
listeningPorts: [Int],
|
|
languageServerCSRFToken: String,
|
|
extensionServerPort: Int?,
|
|
extensionServerCSRFToken: String?) -> [AntigravityConnectionEndpoint]
|
|
{
|
|
var endpoints = Self.languageServerEndpoints(
|
|
listeningPorts: listeningPorts,
|
|
languageServerCSRFToken: languageServerCSRFToken)
|
|
|
|
for endpoint in Self.extensionServerEndpoints(
|
|
extensionServerPort: extensionServerPort,
|
|
languageServerCSRFToken: languageServerCSRFToken,
|
|
extensionServerCSRFToken: extensionServerCSRFToken)
|
|
{
|
|
guard !endpoints.contains(where: { $0.matchesRequestTarget(endpoint) }) else { continue }
|
|
endpoints.append(endpoint)
|
|
}
|
|
|
|
return endpoints
|
|
}
|
|
|
|
static func requestEndpoints(
|
|
resolvedEndpoint: AntigravityConnectionEndpoint,
|
|
listeningPorts: [Int],
|
|
languageServerCSRFToken: String,
|
|
extensionServerPort: Int?,
|
|
extensionServerCSRFToken: String?) -> [AntigravityConnectionEndpoint]
|
|
{
|
|
var endpoints = [resolvedEndpoint]
|
|
|
|
if resolvedEndpoint.source == .extensionServer {
|
|
Self.appendUniqueRequestTargets(
|
|
from: Self.extensionServerEndpoints(
|
|
extensionServerPort: extensionServerPort,
|
|
languageServerCSRFToken: languageServerCSRFToken,
|
|
extensionServerCSRFToken: extensionServerCSRFToken),
|
|
to: &endpoints)
|
|
Self.appendUniqueRequestTargets(
|
|
from: Self.languageServerEndpoints(
|
|
listeningPorts: listeningPorts,
|
|
languageServerCSRFToken: languageServerCSRFToken),
|
|
to: &endpoints)
|
|
} else {
|
|
Self.appendUniqueRequestTargets(
|
|
from: Self.languageServerEndpoints(
|
|
listeningPorts: listeningPorts,
|
|
languageServerCSRFToken: languageServerCSRFToken),
|
|
to: &endpoints)
|
|
Self.appendUniqueRequestTargets(
|
|
from: Self.extensionServerEndpoints(
|
|
extensionServerPort: extensionServerPort,
|
|
languageServerCSRFToken: languageServerCSRFToken,
|
|
extensionServerCSRFToken: extensionServerCSRFToken),
|
|
to: &endpoints)
|
|
}
|
|
|
|
return endpoints
|
|
}
|
|
|
|
private static func languageServerEndpoints(
|
|
listeningPorts: [Int],
|
|
languageServerCSRFToken: String) -> [AntigravityConnectionEndpoint]
|
|
{
|
|
listeningPorts.flatMap { port in
|
|
self.localProbeSchemes.map { scheme in
|
|
AntigravityConnectionEndpoint(
|
|
scheme: scheme,
|
|
port: port,
|
|
csrfToken: languageServerCSRFToken,
|
|
source: .languageServer)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func extensionServerEndpoints(
|
|
extensionServerPort: Int?,
|
|
languageServerCSRFToken: String,
|
|
extensionServerCSRFToken: String?) -> [AntigravityConnectionEndpoint]
|
|
{
|
|
guard let extensionServerPort else { return [] }
|
|
|
|
var endpoints: [AntigravityConnectionEndpoint] = []
|
|
if let extensionServerCSRFToken {
|
|
endpoints.append(
|
|
AntigravityConnectionEndpoint(
|
|
scheme: "http",
|
|
port: extensionServerPort,
|
|
csrfToken: extensionServerCSRFToken,
|
|
source: .extensionServer))
|
|
}
|
|
|
|
if extensionServerCSRFToken != languageServerCSRFToken {
|
|
endpoints.append(
|
|
AntigravityConnectionEndpoint(
|
|
scheme: "http",
|
|
port: extensionServerPort,
|
|
csrfToken: languageServerCSRFToken,
|
|
source: .extensionServer))
|
|
}
|
|
|
|
return endpoints
|
|
}
|
|
|
|
private static func appendUniqueRequestTargets(
|
|
from candidates: [AntigravityConnectionEndpoint],
|
|
to endpoints: inout [AntigravityConnectionEndpoint])
|
|
{
|
|
for endpoint in candidates {
|
|
guard !endpoints.contains(where: { $0.matchesRequestTarget(endpoint) }) else { continue }
|
|
endpoints.append(endpoint)
|
|
}
|
|
}
|
|
|
|
static func resolveWorkingEndpoint(
|
|
candidateEndpoints: [AntigravityConnectionEndpoint],
|
|
timeout: TimeInterval,
|
|
deadline: Date? = nil,
|
|
testConnectivity: @escaping @Sendable (AntigravityConnectionEndpoint, TimeInterval) async -> Bool = Self
|
|
.testEndpointConnectivity) async throws -> AntigravityConnectionEndpoint
|
|
{
|
|
for (index, endpoint) in candidateEndpoints.enumerated() {
|
|
let remainingAttemptCount = candidateEndpoints.count - index
|
|
guard let attemptTimeout = timeoutForEndpointAttempt(
|
|
timeout: timeout,
|
|
deadline: deadline,
|
|
remainingAttemptCount: remainingAttemptCount)
|
|
else {
|
|
throw AntigravityStatusProbeError.timedOut
|
|
}
|
|
let ok = await testConnectivity(endpoint, attemptTimeout)
|
|
if ok { return endpoint }
|
|
}
|
|
if let fallback = fallbackProbeEndpoint(candidateEndpoints) {
|
|
self.log.debug("Port probe fell back to best-effort endpoint", metadata: [
|
|
"source": fallback.source.rawValue,
|
|
"scheme": fallback.scheme,
|
|
"port": "\(fallback.port)",
|
|
])
|
|
return fallback
|
|
}
|
|
throw AntigravityStatusProbeError.portDetectionFailed("no working API port found")
|
|
}
|
|
|
|
private static func timeoutForEndpointAttempt(
|
|
timeout: TimeInterval,
|
|
deadline: Date?,
|
|
remainingAttemptCount: Int) -> TimeInterval?
|
|
{
|
|
guard let deadline else { return timeout }
|
|
let remaining = deadline.timeIntervalSinceNow
|
|
guard remaining > 0 else { return nil }
|
|
return min(timeout, remaining / Double(max(1, remainingAttemptCount)))
|
|
}
|
|
|
|
static func fallbackProbePort(ports: [Int], extensionPort: Int?) -> Int? {
|
|
if let nonExtension = ports.first(where: { $0 != extensionPort }) {
|
|
return nonExtension
|
|
}
|
|
if let extensionPort {
|
|
return extensionPort
|
|
}
|
|
return ports.first
|
|
}
|
|
|
|
static func isReachableProbeError(_ error: Error) -> Bool {
|
|
guard case let AntigravityStatusProbeError.apiError(message) = error else { return false }
|
|
return message.hasPrefix("HTTP ")
|
|
}
|
|
|
|
private static func fallbackProbeEndpoint(
|
|
_ endpoints: [AntigravityConnectionEndpoint]) -> AntigravityConnectionEndpoint?
|
|
{
|
|
if let languageServerEndpoint = endpoints.first(where: { $0.source == .languageServer }) {
|
|
return languageServerEndpoint
|
|
}
|
|
return endpoints.first
|
|
}
|
|
|
|
private static func testEndpointConnectivity(
|
|
_ endpoint: AntigravityConnectionEndpoint,
|
|
timeout: TimeInterval) async -> Bool
|
|
{
|
|
do {
|
|
_ = try await self.makeRequest(
|
|
payload: RequestPayload(
|
|
path: self.unleashPath,
|
|
body: self.unleashRequestBody()),
|
|
context: RequestContext(endpoints: [endpoint], timeout: timeout))
|
|
return true
|
|
} catch {
|
|
if self.isReachableProbeError(error) {
|
|
self.log.debug("Port probe received HTTP response; treating endpoint as reachable", metadata: [
|
|
"source": endpoint.source.rawValue,
|
|
"scheme": endpoint.scheme,
|
|
"port": "\(endpoint.port)",
|
|
"error": error.localizedDescription,
|
|
])
|
|
return true
|
|
}
|
|
self.log.debug("Port probe failed", metadata: [
|
|
"source": endpoint.source.rawValue,
|
|
"scheme": endpoint.scheme,
|
|
"port": "\(endpoint.port)",
|
|
"error": error.localizedDescription,
|
|
])
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - HTTP
|
|
|
|
struct RequestPayload {
|
|
let path: String
|
|
let body: [String: Any]
|
|
}
|
|
|
|
struct RequestContext {
|
|
let endpoints: [AntigravityConnectionEndpoint]
|
|
let timeout: TimeInterval
|
|
let deadline: Date?
|
|
|
|
init(endpoints: [AntigravityConnectionEndpoint], timeout: TimeInterval, deadline: Date? = nil) {
|
|
self.endpoints = endpoints
|
|
self.timeout = timeout
|
|
self.deadline = deadline
|
|
}
|
|
|
|
func timeoutForNextAttempt() -> TimeInterval? {
|
|
AntigravityStatusProbe.timeoutForNextAttempt(timeout: self.timeout, deadline: self.deadline)
|
|
}
|
|
}
|
|
|
|
private static func defaultRequestBody() -> [String: Any] {
|
|
[
|
|
"metadata": [
|
|
"ideName": "antigravity",
|
|
"extensionName": "antigravity",
|
|
"ideVersion": "unknown",
|
|
"locale": "en",
|
|
],
|
|
]
|
|
}
|
|
|
|
private static func unleashRequestBody() -> [String: Any] {
|
|
[
|
|
"context": [
|
|
"properties": [
|
|
"devMode": "false",
|
|
"extensionVersion": "unknown",
|
|
"hasAnthropicModelAccess": "true",
|
|
"ide": "antigravity",
|
|
"ideVersion": "unknown",
|
|
"installationId": "codexbar",
|
|
"language": "UNSPECIFIED",
|
|
"os": "macos",
|
|
"requestedModelId": "MODEL_UNSPECIFIED",
|
|
],
|
|
],
|
|
]
|
|
}
|
|
|
|
static func fetchSnapshot(
|
|
context: RequestContext,
|
|
send: @escaping @Sendable (RequestPayload, AntigravityConnectionEndpoint, TimeInterval) async throws -> Data =
|
|
sendRequest) async throws -> AntigravityStatusSnapshot
|
|
{
|
|
do {
|
|
let quotaSummary = try await self.makeParsedRequest(
|
|
payload: RequestPayload(
|
|
path: self.quotaSummaryPath,
|
|
body: ["forceRefresh": true]),
|
|
context: self.quotaSummaryRequestContext(from: context),
|
|
send: send,
|
|
parse: self.parseQuotaSummaryResponse)
|
|
guard quotaSummary.quotaSummary?.groups.contains(where: { group in
|
|
group.buckets.contains { !$0.disabled && $0.remainingFraction != nil }
|
|
}) == true else {
|
|
throw AntigravityStatusProbeError.parseFailed("Quota summary has no usable quota buckets")
|
|
}
|
|
let identity = try? await self.makeParsedRequest(
|
|
payload: RequestPayload(
|
|
path: self.getUserStatusPath,
|
|
body: self.defaultRequestBody()),
|
|
context: self.identityRequestContext(from: context),
|
|
send: send,
|
|
parse: self.parseUserStatusResponse)
|
|
return quotaSummary.withIdentity(from: identity)
|
|
} catch {
|
|
self.log.debug("Antigravity quota summary unavailable; falling back to model quotas", metadata: [
|
|
"error": error.localizedDescription,
|
|
])
|
|
}
|
|
|
|
do {
|
|
return try await self.makeParsedRequest(
|
|
payload: RequestPayload(
|
|
path: self.getUserStatusPath,
|
|
body: self.defaultRequestBody()),
|
|
context: self.legacyUserStatusRequestContext(from: context),
|
|
send: send,
|
|
parse: self.parseUserStatusResponse)
|
|
} catch {
|
|
return try await self.makeParsedRequest(
|
|
payload: RequestPayload(
|
|
path: self.commandModelConfigPath,
|
|
body: self.defaultRequestBody()),
|
|
context: context,
|
|
send: send,
|
|
parse: self.parseCommandModelResponse)
|
|
}
|
|
}
|
|
|
|
private static func legacyUserStatusRequestContext(from context: RequestContext) -> RequestContext {
|
|
guard let deadline = context.deadline else { return context }
|
|
let remaining = max(0, deadline.timeIntervalSinceNow)
|
|
let userStatusBudget = remaining / 2
|
|
return RequestContext(
|
|
endpoints: context.endpoints,
|
|
timeout: min(context.timeout, userStatusBudget),
|
|
deadline: Date().addingTimeInterval(userStatusBudget))
|
|
}
|
|
|
|
private static func quotaSummaryRequestContext(from context: RequestContext) -> RequestContext {
|
|
guard let deadline = context.deadline else { return context }
|
|
let remaining = max(0, deadline.timeIntervalSinceNow)
|
|
let quotaSummaryBudget = remaining / 2
|
|
return RequestContext(
|
|
endpoints: context.endpoints,
|
|
timeout: min(context.timeout, quotaSummaryBudget),
|
|
deadline: Date().addingTimeInterval(quotaSummaryBudget))
|
|
}
|
|
|
|
private static func identityRequestContext(from context: RequestContext) -> RequestContext {
|
|
RequestContext(
|
|
endpoints: context.endpoints,
|
|
timeout: min(context.timeout, 1),
|
|
deadline: context.deadline)
|
|
}
|
|
|
|
private static func makeRequest(
|
|
payload: RequestPayload,
|
|
context: RequestContext) async throws -> Data
|
|
{
|
|
try await self.sendRequest(payload: payload, context: context)
|
|
}
|
|
|
|
static func makeParsedRequest<T>(
|
|
payload: RequestPayload,
|
|
context: RequestContext,
|
|
send: @escaping @Sendable (RequestPayload, AntigravityConnectionEndpoint, TimeInterval) async throws -> Data =
|
|
sendRequest,
|
|
parse: @escaping @Sendable (Data) throws -> T) async throws -> T
|
|
{
|
|
var lastError: Error?
|
|
|
|
for endpoint in context.endpoints {
|
|
guard let timeout = context.timeoutForNextAttempt() else {
|
|
lastError = lastError ?? AntigravityStatusProbeError.timedOut
|
|
break
|
|
}
|
|
do {
|
|
let data = try await send(payload, endpoint, timeout)
|
|
return try parse(data)
|
|
} catch {
|
|
lastError = error
|
|
Self.log.debug("Antigravity request/parse attempt failed", metadata: [
|
|
"path": payload.path,
|
|
"source": endpoint.source.rawValue,
|
|
"scheme": endpoint.scheme,
|
|
"port": "\(endpoint.port)",
|
|
"error": error.localizedDescription,
|
|
])
|
|
}
|
|
}
|
|
|
|
throw lastError ?? AntigravityStatusProbeError.apiError("Invalid response")
|
|
}
|
|
|
|
private static func sendRequest(
|
|
payload: RequestPayload,
|
|
context: RequestContext) async throws -> Data
|
|
{
|
|
var lastError: Error?
|
|
|
|
for endpoint in context.endpoints {
|
|
guard let timeout = context.timeoutForNextAttempt() else {
|
|
lastError = lastError ?? AntigravityStatusProbeError.timedOut
|
|
break
|
|
}
|
|
do {
|
|
return try await Self.sendRequest(payload: payload, endpoint: endpoint, timeout: timeout)
|
|
} catch {
|
|
lastError = error
|
|
Self.log.debug("Antigravity request attempt failed", metadata: [
|
|
"path": payload.path,
|
|
"source": endpoint.source.rawValue,
|
|
"scheme": endpoint.scheme,
|
|
"port": "\(endpoint.port)",
|
|
"error": error.localizedDescription,
|
|
])
|
|
}
|
|
}
|
|
|
|
throw lastError ?? AntigravityStatusProbeError.apiError("Invalid URL")
|
|
}
|
|
|
|
private static func sendRequest(
|
|
payload: RequestPayload,
|
|
endpoint: AntigravityConnectionEndpoint,
|
|
timeout: TimeInterval) async throws -> Data
|
|
{
|
|
guard let url = URL(string: "\(endpoint.scheme)://127.0.0.1:\(endpoint.port)\(payload.path)") else {
|
|
throw AntigravityStatusProbeError.apiError("Invalid URL")
|
|
}
|
|
|
|
let body = try JSONSerialization.data(withJSONObject: payload.body, options: [])
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.httpBody = body
|
|
request.timeoutInterval = timeout
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue(String(body.count), forHTTPHeaderField: "Content-Length")
|
|
request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version")
|
|
if endpoint.requiresCSRFToken {
|
|
request.setValue(endpoint.csrfToken, forHTTPHeaderField: "X-Codeium-Csrf-Token")
|
|
}
|
|
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.timeoutIntervalForRequest = timeout
|
|
config.timeoutIntervalForResource = timeout
|
|
#if !os(Linux)
|
|
config.waitsForConnectivity = false
|
|
#endif
|
|
|
|
let delegate = LocalhostSessionDelegate()
|
|
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
|
defer { session.invalidateAndCancel() }
|
|
|
|
let (data, response) = try await delegate.data(for: request, session: session)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw AntigravityStatusProbeError.apiError("Invalid response")
|
|
}
|
|
guard http.statusCode == 200 else {
|
|
let message = String(data: data, encoding: .utf8) ?? ""
|
|
throw AntigravityStatusProbeError.apiError("HTTP \(http.statusCode): \(message)")
|
|
}
|
|
return data
|
|
}
|
|
}
|