import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif #if canImport(Darwin) import Darwin #elseif canImport(Glibc) import Glibc #elseif canImport(Musl) import Musl #endif public struct GeminiModelQuota: Sendable { public let modelId: String public let percentLeft: Double public let resetTime: Date? public let resetDescription: String? } public struct GeminiStatusSnapshot: Sendable { public let modelQuotas: [GeminiModelQuota] public let rawText: String public let accountEmail: String? public let accountPlan: String? // Convenience: lowest quota across all models (for icon display) public var lowestPercentLeft: Double? { self.modelQuotas.min(by: { $0.percentLeft < $1.percentLeft })?.percentLeft } /// Legacy compatibility public var dailyPercentLeft: Double? { self.lowestPercentLeft } public var resetDescription: String? { self.modelQuotas.min(by: { $0.percentLeft < $1.percentLeft })?.resetDescription } /// Converts Gemini quotas to a unified UsageSnapshot. /// Groups quotas by tier: Pro (24h window) as primary, Flash (24h window) as secondary, /// Flash Lite (24h window) as tertiary. public func toUsageSnapshot() -> UsageSnapshot { let lower = self.modelQuotas.map { ($0.modelId.lowercased(), $0) } let flashLiteQuotas = lower.filter { Self.isFlashLiteModel(id: $0.0) }.map(\.1) let flashQuotas = lower.filter { Self.isFlashModel(id: $0.0) }.map(\.1) let proQuotas = lower.filter { Self.isProModel(id: $0.0) }.map(\.1) let flashLiteMin = flashLiteQuotas.min(by: { $0.percentLeft < $1.percentLeft }) let flashMin = flashQuotas.min(by: { $0.percentLeft < $1.percentLeft }) let proMin = proQuotas.min(by: { $0.percentLeft < $1.percentLeft }) // Keep missing tiers nil so downstream selectors can fall back to a quota the account actually reports. let primary: RateWindow? = proMin.map { RateWindow( usedPercent: 100 - $0.percentLeft, windowMinutes: 1440, resetsAt: $0.resetTime, resetDescription: $0.resetDescription) } let secondary: RateWindow? = flashMin.map { RateWindow( usedPercent: 100 - $0.percentLeft, windowMinutes: 1440, resetsAt: $0.resetTime, resetDescription: $0.resetDescription) } let tertiary: RateWindow? = flashLiteMin.map { RateWindow( usedPercent: 100 - $0.percentLeft, windowMinutes: 1440, resetsAt: $0.resetTime, resetDescription: $0.resetDescription) } let identity = ProviderIdentitySnapshot( providerID: .gemini, accountEmail: self.accountEmail, accountOrganization: nil, loginMethod: self.accountPlan) return UsageSnapshot( primary: primary, secondary: secondary, tertiary: tertiary, updatedAt: Date(), identity: identity) } private static func isFlashLiteModel(id: String) -> Bool { id.contains("flash-lite") } private static func isFlashModel(id: String) -> Bool { id.contains("flash") && !self.isFlashLiteModel(id: id) } private static func isProModel(id: String) -> Bool { id.contains("pro") } } public enum GeminiStatusProbeError: LocalizedError, Sendable, Equatable { case geminiNotInstalled case notLoggedIn case unsupportedAuthType(String) case consumerTierDeprecated case parseFailed(String) case timedOut case apiError(String) public var errorDescription: String? { switch self { case .geminiNotInstalled: "Gemini CLI is not installed or not on PATH." case .notLoggedIn: "Not logged in to Gemini. Run 'gemini' in Terminal to authenticate." case let .unsupportedAuthType(authType): "Gemini \(authType) auth not supported. Use Google account (OAuth) instead." case .consumerTierDeprecated: GeminiConsumerTierMigration.deprecationError case let .parseFailed(msg): "Could not parse Gemini usage: \(msg)" case .timedOut: "Gemini quota API request timed out." case let .apiError(msg): "Gemini API error: \(msg)" } } /// Detects Google's consumer-tier Gemini CLI shutdown responses. public static func isConsumerTierDeprecationSignal(_ text: String) -> Bool { let normalized = text.lowercased() if normalized.contains("unsupported_client") { return true } if normalized.contains("ineligibletiererror") { return true } if normalized.contains("no longer supported"), normalized.contains("gemini code assist") { return true } if normalized.contains("migrate"), normalized.contains("antigravity"), normalized.contains("gemini") { return true } return false } public static func throwIfConsumerTierDeprecated(data: Data) throws { guard let text = String(data: data, encoding: .utf8), isConsumerTierDeprecationSignal(text) else { return } throw GeminiStatusProbeError.consumerTierDeprecated } } public enum GeminiAuthType: String, Sendable { case oauthPersonal = "oauth-personal" case apiKey = "gemini-api-key" case vertexAI = "vertex-ai" case unknown } /// User tier IDs returned from the Cloud Code Private API (loadCodeAssist). /// Maps to: google3/cloud/developer_experience/cloudcode/pa/service/usertier.go public enum GeminiUserTierId: String, Sendable { case free = "free-tier" case legacy = "legacy-tier" case standard = "standard-tier" } public struct GeminiStatusProbe: Sendable { public var timeout: TimeInterval = 10.0 public var homeDirectory: String public var dataLoader: @Sendable (URLRequest) async throws -> (Data, URLResponse) private static let log = CodexBarLog.logger(LogCategories.geminiProbe) private static let quotaEndpoint = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota" private static let loadCodeAssistEndpoint = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" private static let projectsEndpoint = "https://cloudresourcemanager.googleapis.com/v1/projects" private static let credentialsPath = "/.gemini/oauth_creds.json" private static let settingsPath = "/.gemini/settings.json" private static let tokenRefreshEndpoint = "https://oauth2.googleapis.com/token" public init( timeout: TimeInterval = 10.0, homeDirectory: String = NSHomeDirectory(), dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = Self.defaultDataLoader) { self.timeout = timeout self.homeDirectory = homeDirectory self.dataLoader = dataLoader } /// Reads the current Gemini auth type from settings.json public static func currentAuthType(homeDirectory: String = NSHomeDirectory()) -> GeminiAuthType { let settingsURL = URL(fileURLWithPath: homeDirectory + Self.settingsPath) guard let data = try? Data(contentsOf: settingsURL), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let security = json["security"] as? [String: Any], let auth = security["auth"] as? [String: Any], let selectedType = auth["selectedType"] as? String else { return .unknown } if selectedType == "api-key" { return .apiKey } return GeminiAuthType(rawValue: selectedType) ?? .unknown } public func fetch() async throws -> GeminiStatusSnapshot { // Block explicitly unsupported auth types; allow unknown to try OAuth creds let authType = Self.currentAuthType(homeDirectory: self.homeDirectory) switch authType { case .apiKey: throw GeminiStatusProbeError.unsupportedAuthType("API key") case .vertexAI: throw GeminiStatusProbeError.unsupportedAuthType("Vertex AI") case .oauthPersonal, .unknown: break } let snap = try await Self.fetchViaAPI( timeout: self.timeout, homeDirectory: self.homeDirectory, dataLoader: self.dataLoader) Self.log.info("Gemini API fetch ok", metadata: [ "dailyPercentLeft": "\(snap.dailyPercentLeft ?? -1)", ]) return snap } // MARK: - Direct API approach private static func fetchViaAPI( timeout: TimeInterval, homeDirectory: String, dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws -> GeminiStatusSnapshot { let creds = try Self.loadCredentials(homeDirectory: homeDirectory) let expiryStr = creds.expiryDate.map { "\($0)" } ?? "nil" let hasRefresh = creds.refreshToken != nil Self.log.debug("Token check", metadata: [ "expiry": expiryStr, "hasRefresh": hasRefresh ? "1" : "0", "now": "\(Date())", ]) var accessToken = creds.accessToken?.isEmpty == false ? creds.accessToken : nil var idToken = creds.idToken let needsRefresh = accessToken == nil || creds.expiryDate.map { $0 < Date() } == true if needsRefresh { if accessToken == nil { Self.log.info("No access token found; attempting refresh from stored Gemini credentials") } else if let expiry = creds.expiryDate { Self.log.info("Token expired; attempting refresh", metadata: [ "expiry": "\(expiry)", ]) } guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else { Self.log.error("No refresh token available") throw GeminiStatusProbeError.notLoggedIn } accessToken = try await Self.refreshAccessToken( refreshToken: refreshToken, timeout: timeout, homeDirectory: homeDirectory, dataLoader: dataLoader) idToken = (try? Self.loadCredentials(homeDirectory: homeDirectory).idToken) ?? idToken } guard let accessToken else { Self.log.error("No access token found") throw GeminiStatusProbeError.notLoggedIn } // Extract account info from JWT let claims = Self.extractClaimsFromToken(idToken) // Load Code Assist status to get project ID and tier (aligned with CLI setupUser logic) let caStatus = try await Self.loadCodeAssistStatus( accessToken: accessToken, timeout: timeout, dataLoader: dataLoader) // Determine the project ID to use for quota fetching. // Priority: // 1. Project ID returned by loadCodeAssist (e.g. managed project for free tier) // 2. Discovered project ID from cloud resource manager (e.g. user's own project) var projectId = caStatus.projectId if projectId == nil { projectId = try? await Self.discoverGeminiProjectId( accessToken: accessToken, timeout: timeout, dataLoader: dataLoader) } guard let url = URL(string: Self.quotaEndpoint) else { throw GeminiStatusProbeError.apiError("Invalid endpoint URL") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") // Include project ID for accurate quota if let projectId { request.httpBody = Data("{\"project\": \"\(projectId)\"}".utf8) } else { request.httpBody = Data("{}".utf8) } request.timeoutInterval = timeout let (data, response) = try await dataLoader(request) guard let httpResponse = response as? HTTPURLResponse else { throw GeminiStatusProbeError.apiError("Invalid response") } if httpResponse.statusCode == 401 { try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) throw GeminiStatusProbeError.notLoggedIn } guard httpResponse.statusCode == 200 else { try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) throw GeminiStatusProbeError.apiError("HTTP \(httpResponse.statusCode)") } let snapshot = try Self.parseAPIResponse(data, email: claims.email) let plan = Self.resolveAccountPlan( tier: caStatus.tier, hostedDomain: claims.hostedDomain, paidTierName: caStatus.paidTierName) return GeminiStatusSnapshot( modelQuotas: snapshot.modelQuotas, rawText: snapshot.rawText, accountEmail: snapshot.accountEmail, accountPlan: plan) } private static func discoverGeminiProjectId( accessToken: String, timeout: TimeInterval, dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws -> String? { guard let url = URL(string: projectsEndpoint) else { return nil } var request = URLRequest(url: url) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = timeout let (data, response) = try await dataLoader(request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let projects = json["projects"] as? [[String: Any]] else { return nil } // Look for Gemini API project (has "generative-language" label or "gen-lang-client" prefix) for project in projects { guard let projectId = project["projectId"] as? String else { continue } // Check for gen-lang-client prefix (Gemini CLI projects) if projectId.hasPrefix("gen-lang-client") { return projectId } // Check for generative-language label if let labels = project["labels"] as? [String: String], labels["generative-language"] != nil { return projectId } } return nil } private struct CodeAssistStatus { let tier: GeminiUserTierId? let projectId: String? let paidTierName: String? static let empty = CodeAssistStatus(tier: nil, projectId: nil, paidTierName: nil) } private static func loadCodeAssistStatus( accessToken: String, timeout: TimeInterval, dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws -> CodeAssistStatus { guard let url = URL(string: loadCodeAssistEndpoint) else { self.log.warning("loadCodeAssist: invalid endpoint URL") return .empty } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = Data("{\"metadata\":{\"ideType\":\"GEMINI_CLI\",\"pluginType\":\"GEMINI\"}}".utf8) request.timeoutInterval = timeout let data: Data let response: URLResponse do { (data, response) = try await dataLoader(request) } catch { Self.log.warning("loadCodeAssist: request failed", metadata: ["error": "\(error)"]) return .empty } guard let httpResponse = response as? HTTPURLResponse else { Self.log.warning("loadCodeAssist: invalid response type") return .empty } guard httpResponse.statusCode == 200 else { try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) Self.log.warning("loadCodeAssist: HTTP error", metadata: [ "statusCode": "\(httpResponse.statusCode)", "body": String(data: data, encoding: .utf8) ?? "", ]) return .empty } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { Self.log.warning("loadCodeAssist: failed to parse JSON", metadata: [ "body": String(data: data, encoding: .utf8) ?? "", ]) return .empty } let rawProjectId: String? = { if let project = json["cloudaicompanionProject"] as? String { return project } if let project = json["cloudaicompanionProject"] as? [String: Any] { if let projectId = project["id"] as? String { return projectId } if let projectId = project["projectId"] as? String { return projectId } } return nil }() let trimmedProjectId = rawProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) let projectId = trimmedProjectId?.isEmpty == true ? nil : trimmedProjectId if let projectId { Self.log.info("loadCodeAssist: project detected", metadata: ["projectId": projectId]) } let tierId = (json["currentTier"] as? [String: Any])?["id"] as? String let paidTierName = Self.parsePaidTierName(from: json) guard let tierId else { Self.log.warning("loadCodeAssist: no currentTier.id in response", metadata: [ "json": "\(json)", ]) return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) } guard let tier = GeminiUserTierId(rawValue: tierId) else { Self.log.warning("loadCodeAssist: unknown tier ID", metadata: ["tierId": tierId]) return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) } Self.log.info("loadCodeAssist: success", metadata: [ "tier": tierId, "projectId": projectId ?? "nil", "paidTierName": paidTierName ?? "nil", ]) return CodeAssistStatus(tier: tier, projectId: projectId, paidTierName: paidTierName) } private struct OAuthCredentials { let accessToken: String? let idToken: String? let refreshToken: String? let expiryDate: Date? } fileprivate struct OAuthClientCredentials { let clientId: String let clientSecret: String } private static func isLikelyFnmManagedPath(_ path: String) -> Bool { let normalized = path.replacingOccurrences(of: "\\", with: "/") return normalized.contains("/fnm_multishells/") || (normalized.contains("/node-versions/") && normalized.contains("/fnm/")) } private static func resolveExecutableOnEnvironmentPath( named executable: String, environment: [String: String]) -> String? { guard let path = environment["PATH"] else { return nil } for directory in path.split(separator: ":") where !directory.isEmpty { let candidate = URL(fileURLWithPath: String(directory), isDirectory: true) .appendingPathComponent(executable) .path if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } } return nil } private static func resolveGeminiPackageRootViaFnm( fnmPath: String, environment: [String: String]) -> String? { guard let currentVersion = runProcess( executable: fnmPath, arguments: ["current"], environment: environment, timeout: 2.0), !currentVersion.isEmpty else { return nil } // Prefer npm root -g because require.resolve searches from the current // working directory and often fails for globally-installed packages. if let npmRoot = runProcess( executable: fnmPath, arguments: [ "exec", "--using", currentVersion, "npm", "root", "-g", ], environment: environment, timeout: 4.0), !npmRoot.isEmpty { let packageRoot = "\(npmRoot)/@google/gemini-cli" let packageJSONPath = "\(packageRoot)/package.json" if FileManager.default.fileExists(atPath: packageJSONPath) { return packageRoot } } // Fallback for non-npm global installations. if let packageJSONPath = runProcess( executable: fnmPath, arguments: [ "exec", "--using", currentVersion, "node", "-p", "require.resolve('@google/gemini-cli/package.json')", ], environment: environment, timeout: 4.0), !packageJSONPath.isEmpty { return (packageJSONPath as NSString).deletingLastPathComponent } return nil } private static func findGeminiPackageRoot(startingAt path: String) -> String? { let fileManager = FileManager.default var currentURL = URL(fileURLWithPath: path).standardizedFileURL var isDirectory: ObjCBool = false if !fileManager.fileExists(atPath: currentURL.path, isDirectory: &isDirectory) || !isDirectory.boolValue { currentURL.deleteLastPathComponent() } // Bound the walk so an unrelated Gemini install elsewhere on the host // (e.g. a global npm/brew install unrelated to the resolved binary) can't // contaminate discovery started from the actual binary path. let maxAscents = 8 for _ in 0...maxAscents { let packageJSONURL = currentURL.appendingPathComponent("package.json") if let data = try? Data(contentsOf: packageJSONURL), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], json["name"] as? String == "@google/gemini-cli" { return currentURL.path } // Also check for a global Node installation layout: // /lib/node_modules/@google/gemini-cli/package.json let globalPackageJSONURL = currentURL .appendingPathComponent("lib") .appendingPathComponent("node_modules") .appendingPathComponent("@google") .appendingPathComponent("gemini-cli") .appendingPathComponent("package.json") if let data = try? Data(contentsOf: globalPackageJSONURL), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], json["name"] as? String == "@google/gemini-cli" { return globalPackageJSONURL.deletingLastPathComponent().path } // Homebrew layout: // /libexec/lib/node_modules/@google/gemini-cli/package.json let homebrewPackageJSONURL = currentURL .appendingPathComponent("libexec") .appendingPathComponent("lib") .appendingPathComponent("node_modules") .appendingPathComponent("@google") .appendingPathComponent("gemini-cli") .appendingPathComponent("package.json") if let data = try? Data(contentsOf: homebrewPackageJSONURL), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], json["name"] as? String == "@google/gemini-cli" { return homebrewPackageJSONURL.deletingLastPathComponent().path } let parentURL = currentURL.deletingLastPathComponent() if parentURL.path == currentURL.path { return nil } currentURL = parentURL } return nil } private static func extractOAuthCredentials(fromGeminiPackageRoot packageRoot: String) -> OAuthClientCredentials? { // Check the standard distributed file first, then any sibling core package let oauthFile = "dist/src/code_assist/oauth2.js" let candidatePaths = [ "\(packageRoot)/\(oauthFile)", "\(packageRoot)/node_modules/@google/gemini-cli-core/\(oauthFile)", ] for path in candidatePaths { if let content = try? String(contentsOfFile: path, encoding: .utf8), let credentials = Self.parseOAuthCredentials(from: content) { return credentials } } return Self.extractOAuthCredentialsFromBundle(packageRoot: packageRoot) } private static func extractOAuthCredentialsFromBundle(packageRoot: String) -> OAuthClientCredentials? { let bundleRoot = URL(fileURLWithPath: packageRoot).appendingPathComponent("bundle", isDirectory: true) let entryURL = bundleRoot.appendingPathComponent("gemini.js") guard FileManager.default.fileExists(atPath: entryURL.path) else { return nil } var pendingURLs = [entryURL] var visitedPaths = Set() while !pendingURLs.isEmpty { let currentURL = pendingURLs.removeFirst() let standardizedPath = currentURL.standardizedFileURL.path guard visitedPaths.insert(standardizedPath).inserted, let content = try? String(contentsOf: currentURL, encoding: .utf8) else { continue } if let credentials = Self.parseOAuthCredentials(from: content) { return credentials } let imports = Self.extractRelativeJavaScriptImports(from: content) for importPath in imports { let nextURL = URL(fileURLWithPath: importPath, relativeTo: currentURL.deletingLastPathComponent()) .standardizedFileURL guard nextURL.path.hasPrefix(bundleRoot.path) else { continue } pendingURLs.append(nextURL) } } guard let bundleFiles = try? FileManager.default.contentsOfDirectory( at: bundleRoot, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return nil } for url in bundleFiles where url.pathExtension == "js" && !visitedPaths.contains(url.standardizedFileURL.path) { guard let content = try? String(contentsOf: url, encoding: .utf8) else { continue } if let credentials = Self.parseOAuthCredentials(from: content) { return credentials } } return nil } private static func extractRelativeJavaScriptImports(from content: String) -> [String] { let patterns = [ #"(?:import|export)\s+(?:[^;]*?\s+from\s+)?[\"'](\./[^\"']+\.js)[\"']"#, #"import\(\s*[\"'](\./[^\"']+\.js)[\"']\s*\)"#, ] var discoveredPaths: [String] = [] var seen = Set() let fullRange = NSRange(content.startIndex..., in: content) for pattern in patterns { guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } for match in regex.matches(in: content, range: fullRange) { guard let range = Range(match.range(at: 1), in: content) else { continue } let path = String(content[range]) if seen.insert(path).inserted { discoveredPaths.append(path) } } } return discoveredPaths } private static func extractOAuthCredentialsFromLegacyPaths(realGeminiPath: String) -> OAuthClientCredentials? { let binDir = (realGeminiPath as NSString).deletingLastPathComponent let baseDir = (binDir as NSString).deletingLastPathComponent let oauthSubpath = "node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js" let nixShareSubpath = "share/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js" let oauthFile = "dist/src/code_assist/oauth2.js" let possiblePaths = [ // Homebrew nested structure "\(baseDir)/libexec/lib/\(oauthSubpath)", "\(baseDir)/lib/\(oauthSubpath)", // Nix package layout "\(baseDir)/\(nixShareSubpath)", // Bun/npm sibling structure: gemini-cli-core is a sibling to gemini-cli "\(baseDir)/../gemini-cli-core/\(oauthFile)", // npm nested inside gemini-cli "\(baseDir)/node_modules/@google/gemini-cli-core/\(oauthFile)", ] for path in possiblePaths { if let content = try? String(contentsOfFile: path, encoding: .utf8), let credentials = Self.parseOAuthCredentials(from: content) { return credentials } } return nil } private static func parseOAuthCredentials(from content: String) -> OAuthClientCredentials? { // Match: const/let/var OAUTH_CLIENT_ID = '...'; let clientIdPattern = #"(?:const|let|var)?\s*OAUTH_CLIENT_ID\s*=\s*['"]([\w\-\.]+)['"]\s*;"# let secretPattern = #"(?:const|let|var)?\s*OAUTH_CLIENT_SECRET\s*=\s*['"]([\w\-]+)['"]\s*;"# guard let clientIdRegex = try? NSRegularExpression(pattern: clientIdPattern), let secretRegex = try? NSRegularExpression(pattern: secretPattern) else { return nil } let range = NSRange(content.startIndex..., in: content) guard let clientIdMatch = clientIdRegex.firstMatch(in: content, range: range), let clientIdRange = Range(clientIdMatch.range(at: 1), in: content), let secretMatch = secretRegex.firstMatch(in: content, range: range), let secretRange = Range(secretMatch.range(at: 1), in: content) else { return nil } let clientId = String(content[clientIdRange]) let clientSecret = String(content[secretRange]) return OAuthClientCredentials(clientId: clientId, clientSecret: clientSecret) } private static func refreshAccessToken( refreshToken: String, timeout: TimeInterval, homeDirectory: String, dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws -> String { guard let url = URL(string: tokenRefreshEndpoint) else { throw GeminiStatusProbeError.apiError("Invalid token refresh URL") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.timeoutInterval = timeout guard let oauthCreds = Self.extractOAuthCredentials() else { Self.log.error("Could not extract OAuth credentials from Gemini CLI") throw GeminiStatusProbeError.apiError(GeminiConsumerTierMigration.oauthRecoveryError) } let body = [ "client_id=\(oauthCreds.clientId)", "client_secret=\(oauthCreds.clientSecret)", "refresh_token=\(refreshToken)", "grant_type=refresh_token", ].joined(separator: "&") request.httpBody = body.data(using: .utf8) let (data, response) = try await dataLoader(request) guard let httpResponse = response as? HTTPURLResponse else { throw GeminiStatusProbeError.apiError("Invalid refresh response") } guard httpResponse.statusCode == 200 else { try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) Self.log.error("Token refresh failed", metadata: [ "statusCode": "\(httpResponse.statusCode)", ]) throw GeminiStatusProbeError.notLoggedIn } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let newAccessToken = json["access_token"] as? String else { throw GeminiStatusProbeError.parseFailed("Could not parse refresh response") } // Update stored credentials with new token try Self.updateStoredCredentials(json, homeDirectory: homeDirectory) Self.log.info("Token refreshed successfully") return newAccessToken } private static func updateStoredCredentials(_ refreshResponse: [String: Any], homeDirectory: String) throws { let credsURL = URL(fileURLWithPath: homeDirectory + Self.credentialsPath) guard let existingCreds = try? Data(contentsOf: credsURL), var json = try? JSONSerialization.jsonObject(with: existingCreds) as? [String: Any] else { return } // Update with new values from refresh response if let accessToken = refreshResponse["access_token"] { json["access_token"] = accessToken } if let expiresIn = refreshResponse["expires_in"] as? Double { json["expiry_date"] = (Date().timeIntervalSince1970 + expiresIn) * 1000 } if let idToken = refreshResponse["id_token"] { json["id_token"] = idToken } let updatedData = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) try updatedData.write(to: credsURL, options: .atomic) } private static func loadCredentials(homeDirectory: String) throws -> OAuthCredentials { let credsURL = URL(fileURLWithPath: homeDirectory + Self.credentialsPath) guard FileManager.default.fileExists(atPath: credsURL.path) else { throw GeminiStatusProbeError.notLoggedIn } let data = try Data(contentsOf: credsURL) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw GeminiStatusProbeError.parseFailed("Invalid credentials file") } let accessToken = json["access_token"] as? String let idToken = json["id_token"] as? String let refreshToken = json["refresh_token"] as? String var expiryDate: Date? if let expiryMs = json["expiry_date"] as? Double { expiryDate = Date(timeIntervalSince1970: expiryMs / 1000) } return OAuthCredentials( accessToken: accessToken, idToken: idToken, refreshToken: refreshToken, expiryDate: expiryDate) } private struct TokenClaims { let email: String? let hostedDomain: String? } private static func extractClaimsFromToken(_ idToken: String?) -> TokenClaims { guard let token = idToken else { return TokenClaims(email: nil, hostedDomain: nil) } let parts = token.components(separatedBy: ".") guard parts.count >= 2 else { return TokenClaims(email: nil, hostedDomain: nil) } var payload = parts[1] .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") let remainder = payload.count % 4 if remainder > 0 { payload += String(repeating: "=", count: 4 - remainder) } guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return TokenClaims(email: nil, hostedDomain: nil) } return TokenClaims( email: json["email"] as? String, hostedDomain: json["hd"] as? String) } private static func extractEmailFromToken(_ idToken: String?) -> String? { self.extractClaimsFromToken(idToken).email } private struct QuotaBucket: Decodable { let remainingFraction: Double? let resetTime: String? let modelId: String? let tokenType: String? } private struct QuotaResponse: Decodable { let buckets: [QuotaBucket]? } private static func parseAPIResponse(_ data: Data, email: String?) throws -> GeminiStatusSnapshot { let decoder = JSONDecoder() let response = try decoder.decode(QuotaResponse.self, from: data) guard let buckets = response.buckets, !buckets.isEmpty else { throw GeminiStatusProbeError.parseFailed("No quota buckets in response") } // Group quotas by model, keeping lowest per model (input tokens usually) var modelQuotaMap: [String: (fraction: Double, resetString: String?)] = [:] for bucket in buckets { guard let modelId = bucket.modelId, let fraction = bucket.remainingFraction else { continue } if let existing = modelQuotaMap[modelId] { if fraction < existing.fraction { modelQuotaMap[modelId] = (fraction, bucket.resetTime) } } else { modelQuotaMap[modelId] = (fraction, bucket.resetTime) } } // Convert to sorted array (by model name for consistent ordering) let quotas = modelQuotaMap .sorted { $0.key < $1.key } .map { modelId, info in let resetDate = info.resetString.flatMap { Self.parseResetTime($0) } return GeminiModelQuota( modelId: modelId, percentLeft: info.fraction * 100, resetTime: resetDate, resetDescription: info.resetString.flatMap { Self.formatResetTime($0) }) } let rawText = String(data: data, encoding: .utf8) ?? "" return GeminiStatusSnapshot( modelQuotas: quotas, rawText: rawText, accountEmail: email, accountPlan: nil) } private static func parseResetTime(_ isoString: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: isoString) { return date } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: isoString) } private static func formatResetTime(_ isoString: String) -> String { guard let resetDate = parseResetTime(isoString) else { return "Resets soon" } let now = Date() let interval = resetDate.timeIntervalSince(now) if interval <= 0 { return "Resets soon" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) if hours > 0 { return "Resets in \(hours)h \(minutes)m" } else { return "Resets in \(minutes)m" } } // MARK: - Legacy CLI parsing (kept for fallback) public static func parse(text: String) throws -> GeminiStatusSnapshot { let clean = TextParsing.stripANSICodes(text) guard !clean.isEmpty else { throw GeminiStatusProbeError.timedOut } let quotas = Self.parseModelUsageTable(clean) if quotas.isEmpty { if clean.contains("Login with Google") || clean.contains("Use Gemini API key") { throw GeminiStatusProbeError.notLoggedIn } if clean.contains("Waiting for auth"), !clean.contains("Usage") { throw GeminiStatusProbeError.notLoggedIn } throw GeminiStatusProbeError.parseFailed("No usage data found in /stats output") } return GeminiStatusSnapshot( modelQuotas: quotas, rawText: text, accountEmail: nil, accountPlan: nil) } private static func parseModelUsageTable(_ text: String) -> [GeminiModelQuota] { let lines = text.components(separatedBy: .newlines) var quotas: [GeminiModelQuota] = [] let pattern = #"(gemini[-\w.]+)\s+[\d-]+\s+([0-9]+(?:\.[0-9]+)?)\s*%\s*\(([^)]+)\)"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] } for line in lines { let cleanLine = line.replacingOccurrences(of: "│", with: " ") let range = NSRange(cleanLine.startIndex..= 4 else { continue } guard let modelRange = Range(match.range(at: 1), in: cleanLine), let pctRange = Range(match.range(at: 2), in: cleanLine), let pct = Double(cleanLine[pctRange]) else { continue } let modelId = String(cleanLine[modelRange]) var resetDesc: String? if let resetRange = Range(match.range(at: 3), in: cleanLine) { resetDesc = String(cleanLine[resetRange]).trimmingCharacters(in: .whitespaces) } quotas.append(GeminiModelQuota( modelId: modelId, percentLeft: pct, resetTime: nil, resetDescription: resetDesc)) } return quotas } } extension GeminiStatusProbe { fileprivate static func extractOAuthCredentials() -> OAuthClientCredentials? { if let resolved = GeminiOAuthConfig.environmentClient() { return OAuthClientCredentials(clientId: resolved.clientID, clientSecret: resolved.clientSecret) } if let path = GeminiOAuthConfig.configuredOAuth2JSPath, let credentials = Self.parseOAuthCredentials(fromFile: path) { return credentials } if let credentials = Self.discoverOAuthCredentialsFromInstalledCLI() { return OAuthClientCredentials(clientId: credentials.clientID, clientSecret: credentials.clientSecret) } if let credentials = Self.discoverOAuthCredentialsFromKnownInstallPaths() { return credentials } return nil } /// Optional Homebrew/npm prefixes for last-resort discovery when the gemini /// binary cannot be resolved (GUI PATH / sandbox). Tests inject fake Cellar trees. @TaskLocal public static var knownInstallPrefixesForTesting: [String]? fileprivate static func discoverOAuthCredentialsFromKnownInstallPaths() -> OAuthClientCredentials? { let oauthFile = "dist/src/code_assist/oauth2.js" let nestedOAuthFile = "node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/\(oauthFile)" let home = FileManager.default.homeDirectoryForCurrentUser.path // When tests inject Homebrew prefixes, skip the hard-coded host paths so // fixture Cellar trees are not shadowed by the developer's real install. if Self.knownInstallPrefixesForTesting == nil { let possiblePaths = [ "/opt/homebrew/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", "/opt/homebrew/lib/\(nestedOAuthFile)", "/usr/local/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", "/usr/local/lib/\(nestedOAuthFile)", "\(home)/.npm-global/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", "\(home)/.npm-global/lib/\(nestedOAuthFile)", ] for path in possiblePaths { if let credentials = Self.parseOAuthCredentials(fromFile: path) { return credentials } } } // Homebrew Cellar/opt libexec layouts keep credentials inside the package // bundle when GUI PATH cannot resolve `/opt/homebrew/bin/gemini`. for packageRoot in Self.knownHomebrewGeminiPackageRoots() { if let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) { return credentials } } return nil } fileprivate static func knownHomebrewPrefixes() -> [String] { if let overrides = self.knownInstallPrefixesForTesting, !overrides.isEmpty { return overrides } return ["/opt/homebrew", "/usr/local"] } fileprivate static func knownHomebrewGeminiPackageRoots( fileManager: FileManager = .default) -> [String] { var roots: [String] = [] var seen = Set() func appendPackageRoot(under prefixRoot: String) { let packageRoot = URL(fileURLWithPath: prefixRoot) .appendingPathComponent("libexec") .appendingPathComponent("lib") .appendingPathComponent("node_modules") .appendingPathComponent("@google") .appendingPathComponent("gemini-cli") .path guard fileManager.fileExists(atPath: packageRoot), seen.insert(packageRoot).inserted else { return } roots.append(packageRoot) } for prefix in Self.knownHomebrewPrefixes() { let optRoot = "\(prefix)/opt/gemini-cli" if fileManager.fileExists(atPath: optRoot) { appendPackageRoot(under: optRoot) } let cellarRoot = "\(prefix)/Cellar/gemini-cli" guard let versions = try? fileManager.contentsOfDirectory(atPath: cellarRoot) else { continue } for version in versions.sorted() { appendPackageRoot(under: "\(cellarRoot)/\(version)") } } return roots } fileprivate static func parseOAuthCredentials(fromFile path: String) -> OAuthClientCredentials? { guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } return Self.parseOAuthCredentials(from: content) } fileprivate static func discoverOAuthCredentialsFromInstalledCLI() -> GeminiOAuthConfig.ClientCredentials? { let env = ProcessInfo.processInfo.environment guard let geminiPath = BinaryLocator.resolveGeminiBinary( env: env, loginPATH: LoginShellPathCache.shared.current) ?? TTYCommandRunner.which("gemini") else { return nil } let resolvedGeminiPath = URL(fileURLWithPath: geminiPath).resolvingSymlinksInPath().path if let credentials = Self.extractOAuthCredentialsFromLegacyPaths(realGeminiPath: resolvedGeminiPath) { return GeminiOAuthConfig.ClientCredentials( clientID: credentials.clientId, clientSecret: credentials.clientSecret) } if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath), let fnmPath = Self.resolveExecutableOnEnvironmentPath(named: "fnm", environment: env) ?? TTYCommandRunner.which("fnm"), let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env), let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) { return GeminiOAuthConfig.ClientCredentials( clientID: credentials.clientId, clientSecret: credentials.clientSecret) } if let packageRoot = Self.findGeminiPackageRoot(startingAt: resolvedGeminiPath), let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) { return GeminiOAuthConfig.ClientCredentials( clientID: credentials.clientId, clientSecret: credentials.clientSecret) } return nil } /// Plan display strings with tier mapping: /// - paidTier.name: Most-specific paid subscription label from Google, regardless of currentTier /// - standard-tier: Paid subscription fallback (Code Assist Standard/Enterprise, Developer Program Premium) /// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025) /// - free-tier: Personal free account /// - legacy-tier: Unknown legacy/grandfathered tier /// - nil (API failed): Leave blank (no display) fileprivate static func resolveAccountPlan( tier: GeminiUserTierId?, hostedDomain: String?, paidTierName: String?) -> String? { // Match Gemini CLI's contract: a named paid tier is the most specific plan signal, // even when currentTier is missing, unknown, or still reports free-tier. if let paidTierName { self.log.info("Paid tier detected", metadata: [ "tier": tier?.rawValue ?? "unknown", "plan": paidTierName, ]) return paidTierName } switch (tier, hostedDomain) { case (.standard, _): return "Paid" case let (.free, .some(domain)): Self.log.info("Workspace account detected", metadata: ["domain": domain]) return "Workspace" case (.free, .none): Self.log.info("Personal free account") return "Free" case (.legacy, _): return "Legacy" case (.none, _): self.log.info("Tier detection failed, leaving plan blank") return nil } } private static func parsePaidTierName(from json: [String: Any]) -> String? { guard let paidTier = json["paidTier"] as? [String: Any], let rawName = paidTier["name"] as? String else { return nil } let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } } extension GeminiStatusProbe { private static let processTimeoutQueue = DispatchQueue( label: "com.steipete.codexbar.gemini-process-timeout", qos: .utility, attributes: .concurrent) package static func runProcess( executable: String, arguments: [String], environment: [String: String], timeout: TimeInterval) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments var mergedEnvironment = environment mergedEnvironment["PATH"] = PathBuilder.effectivePATH( purposes: [.tty, .nodeTooling], env: environment, loginPATH: LoginShellPathCache.shared.current) process.environment = mergedEnvironment let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr process.standardInput = nil let stdoutCapture = ProcessPipeCapture(pipe: stdout) let stderrCapture = ProcessPipeCapture(pipe: stderr) do { try process.run() } catch { stdoutCapture.stop() stdout.fileHandleForWriting.closeFile() stderrCapture.stop() stderr.fileHandleForWriting.closeFile() return nil } // Process has duplicated these descriptors. Closing the parent's copies lets readers observe EOF as // soon as the helper exits instead of spending the full drain grace period on every successful call. stdout.fileHandleForWriting.closeFile() stderr.fileHandleForWriting.closeFile() stdoutCapture.start() stderrCapture.start() let pid = process.processIdentifier let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil // Wait synchronously for Foundation to reap the helper. A separate timer owns timeout termination, // so neither termination-handler scheduling nor stale Process.isRunning state controls normal exits. let timeoutState = GeminiProcessTimeoutState() let timeoutTimer = DispatchSource.makeTimerSource(queue: Self.processTimeoutQueue) timeoutTimer.schedule(deadline: .now() + max(0, timeout)) timeoutTimer.setEventHandler { guard process.isRunning else { return } timeoutState.markTimedOut() SubprocessRunner.terminateProcess(process, processGroup: processGroup) } timeoutTimer.resume() process.waitUntilExit() timeoutTimer.cancel() if timeoutState.didTimeOut { stdoutCapture.stop() stderrCapture.stop() return nil } let data = stdoutCapture.finishFirstLineSynchronously(timeout: 1) stderrCapture.stop() let output = ProcessPipeCapture.decodeUTF8(data) .trimmingCharacters(in: .whitespacesAndNewlines) guard process.terminationStatus == 0, !output.isEmpty else { return nil } return output.components(separatedBy: .newlines).first? .trimmingCharacters(in: .whitespacesAndNewlines) } } private final class GeminiProcessTimeoutState: @unchecked Sendable { private let lock = NSLock() private var timedOut = false var didTimeOut: Bool { self.lock.lock() defer { self.lock.unlock() } return self.timedOut } func markTimedOut() { self.lock.lock() self.timedOut = true self.lock.unlock() } }