diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92ac5dda..6b6e44ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,9 @@ on: paths: - '**.swift' - '**.xcodeproj/**' + - 'scripts/claude-version.sh' + - 'scripts/query-claude.sh' + - 'scripts/tests/query-claude-version-test.sh' - '.github/workflows/test.yml' pull_request: branches: @@ -16,6 +19,9 @@ on: paths: - '**.swift' - '**.xcodeproj/**' + - 'scripts/claude-version.sh' + - 'scripts/query-claude.sh' + - 'scripts/tests/query-claude-version-test.sh' - '.github/workflows/test.yml' workflow_dispatch: @@ -27,6 +33,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + + - name: Test Claude version detection + run: bash scripts/tests/query-claude-version-test.sh - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index 1e7d12f2..b9619f34 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0B6CF8BEB936A2055F19AA8C /* CodexProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76783C44AA2329AE3FA7E981 /* CodexProvider.swift */; }; 0EA5E7A5BCD47582D6C2175B /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF0C48368166F3AD0C79F92 /* TokenManager.swift */; }; 23AA5FD37F2062EACCA79F50 /* GeminiCLIProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B85E0F2A8C591D7E26AE2 /* GeminiCLIProviderTests.swift */; }; + ANTIGRAVITYTESTBF11111 /* AntigravityProviderVarintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ANTIGRAVITYTESTFR11111 /* AntigravityProviderVarintTests.swift */; }; 27A0B56ADAC5B75A3270F90D /* CopilotProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4AE672516E564CC52739BD9 /* CopilotProvider.swift */; }; 283348F92F313096004DADE1 /* ProviderResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = PR2222222222222222222222 /* ProviderResult.swift */; }; 283348FA2F313096004DADE1 /* UsageHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44444444444444444444444 /* UsageHistory.swift */; }; @@ -186,6 +187,7 @@ AD0CA52627AF05BD67B56E /* StatusBarIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarIconView.swift; sourceTree = ""; }; AM2222222222222222222222 /* AppMigrationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigrationHelper.swift; sourceTree = ""; }; B63B85E0F2A8C591D7E26AE2 /* GeminiCLIProviderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GeminiCLIProviderTests.swift; sourceTree = ""; }; + ANTIGRAVITYTESTFR11111 /* AntigravityProviderVarintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityProviderVarintTests.swift; sourceTree = ""; }; BE39D5663A7ABADC7E54B91D /* ProviderProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProviderProtocol.swift; sourceTree = ""; }; C1C7C9FDB4600114F02B2915 /* MenuDesignToken.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MenuDesignToken.swift; sourceTree = ""; }; C8493409A9188CFF5B73D5B3 /* MenuDesignTokenTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MenuDesignTokenTests.swift; sourceTree = ""; }; @@ -445,6 +447,7 @@ T22222222222222222222222 /* ProviderUsageTests.swift */, TOOOOOOOOOOOOOOOOOOOOOOO /* Fixtures */, B63B85E0F2A8C591D7E26AE2 /* GeminiCLIProviderTests.swift */, + ANTIGRAVITYTESTFR11111 /* AntigravityProviderVarintTests.swift */, ED35D8BD06A436306085737D /* CopilotProviderTests.swift */, OR3333333333333333333333 /* OpenRouterProviderTests.swift */, 2D2B4000E33060BA5E00351B /* DependencyTests.swift */, @@ -704,6 +707,7 @@ files = ( T11111111111111111111111 /* ProviderUsageTests.swift in Sources */, 23AA5FD37F2062EACCA79F50 /* GeminiCLIProviderTests.swift in Sources */, + ANTIGRAVITYTESTBF11111 /* AntigravityProviderVarintTests.swift in Sources */, 632E52F6050FAD5CAF928E36 /* CopilotProviderTests.swift in Sources */, OR4444444444444444444444 /* OpenRouterProviderTests.swift in Sources */, 668B6906C95903D51823808A /* DependencyTests.swift in Sources */, diff --git a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift index c73f9d19..dff62312 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift @@ -166,7 +166,7 @@ final class AntigravityProvider: ProviderProtocol { private func fetchFromAccountsFallback(cacheError: Error) async throws -> ProviderResult { guard let account = resolveFallbackAccount() else { throw ProviderError.providerError( - "Antigravity cache unavailable and no enabled antigravity-accounts.json account with project ID was found" + "Antigravity cache unavailable and no enabled antigravity-accounts.json account with a refresh token was found" ) } @@ -174,33 +174,11 @@ final class AntigravityProvider: ProviderProtocol { throw ProviderError.authenticationFailed("Unable to refresh Antigravity fallback token") } - guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") else { - throw ProviderError.networkError("Invalid API endpoint") - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = "{\"project\":\"\(account.projectId)\"}".data(using: .utf8) - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ProviderError.networkError("Invalid response type") - } - - if httpResponse.statusCode == 401 { - throw ProviderError.authenticationFailed("Antigravity fallback token expired") - } - - guard (200...299).contains(httpResponse.statusCode) else { - throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") - } - - let quotaResponse = try JSONDecoder().decode(GeminiQuotaResponse.self, from: data) - guard !quotaResponse.buckets.isEmpty else { - throw ProviderError.decodingError("Empty buckets array") - } + let quotaResponse = try await GeminiQuotaAPI.fetchQuota( + accessToken: accessToken, + projectId: account.projectId, + session: session + ) let parsed = parseQuotaBuckets(quotaResponse.buckets) let minRemaining = parsed.modelBreakdown.values.min() ?? 0.0 @@ -240,46 +218,22 @@ final class AntigravityProvider: ProviderProtocol { return nil } - let preferredIndexes: [Int?] = [ - antigravityAccounts.activeIndexByFamily?["gemini"], - antigravityAccounts.activeIndex - ] - - func accountAtPreferredIndex() -> AntigravityAccounts.Account? { - for preferredIndex in preferredIndexes { - guard let index = preferredIndex, - antigravityAccounts.accounts.indices.contains(index) else { - continue - } - - let account = antigravityAccounts.accounts[index] - if account.enabled == false { - continue - } - - return account - } - - return antigravityAccounts.accounts.first(where: { $0.enabled != false }) - } - - guard let account = accountAtPreferredIndex() else { - logger.warning("Antigravity fallback unavailable: no enabled account found") + guard let account = Self.selectFallbackAccount(from: antigravityAccounts) else { + logger.warning("Antigravity fallback unavailable: no enabled account with a refresh token found") return nil } - let refreshToken = account.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !refreshToken.isEmpty else { + guard let refreshToken = nonEmptyTrimmed(account.refreshToken) else { logger.warning("Antigravity fallback unavailable: selected account is missing refresh token") return nil } - let primaryProjectId = account.projectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let fallbackProjectId = account.managedProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let projectId = primaryProjectId.isEmpty ? fallbackProjectId : primaryProjectId - guard !projectId.isEmpty else { - logger.warning("Antigravity fallback unavailable: selected account is missing project ID") - return nil + let projectId = GeminiProjectPolicy.resolve( + primary: account.projectId, + fallback: account.managedProjectId + ) + if projectId == GeminiProjectPolicy.fallbackProjectId { + logger.info("Antigravity fallback account has no project ID; using default project fallback") } return AntigravityFallbackAccount( @@ -294,6 +248,31 @@ final class AntigravityProvider: ProviderProtocol { ) } + static func selectFallbackAccount(from accounts: AntigravityAccounts) -> AntigravityAccounts.Account? { + let preferredIndexes: [Int?] = [ + accounts.activeIndexByFamily?["gemini"], + accounts.activeIndex + ] + var orderedIndexes = preferredIndexes.compactMap { $0 } + orderedIndexes.append(contentsOf: accounts.accounts.indices) + var visitedIndexes = Set() + + for index in orderedIndexes { + guard visitedIndexes.insert(index).inserted, + accounts.accounts.indices.contains(index) else { + continue + } + + let account = accounts.accounts[index] + let refreshToken = account.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if account.enabled != false, !refreshToken.isEmpty { + return account + } + } + + return nil + } + private func parseQuotaBuckets(_ buckets: [GeminiQuotaResponse.Bucket]) -> AntigravityParsedCacheUsage { var modelBreakdown: [String: Double] = [:] var modelResetTimes: [String: Date] = [:] diff --git a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift index f43fa328..535db4d7 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ClaudeProvider.swift @@ -8,38 +8,168 @@ enum ClaudeOAuthRequestPolicy { static let betaHeader = "oauth-2025-04-20" static let defaultClaudeCodeVersion = "2.1.80" - static func codeVersion(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + static func codeVersion( + environment: [String: String] = ProcessInfo.processInfo.environment, + installedVersion: String? = nil + ) -> String { let rawVersion = environment["ANTHROPIC_CLI_VERSION"]?.trimmingCharacters(in: .whitespacesAndNewlines) - if let rawVersion, !rawVersion.isEmpty { - return rawVersion + if let overrideVersion = normalizedVersion(rawVersion) { + return overrideVersion } + + if let installedVersion = normalizedVersion(installedVersion) { + return installedVersion + } + return defaultClaudeCodeVersion } - static func usageUserAgent(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + static func usageUserAgent( + environment: [String: String] = ProcessInfo.processInfo.environment, + installedVersion: String? = nil + ) -> String { let rawUserAgent = environment["ANTHROPIC_CODE_USER_AGENT"]?.trimmingCharacters(in: .whitespacesAndNewlines) if let rawUserAgent, !rawUserAgent.isEmpty { return rawUserAgent } - return "claude-code/\(codeVersion(environment: environment))" + return "claude-code/\(codeVersion(environment: environment, installedVersion: installedVersion))" + } + + static func installedClaudeCodeVersion( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) async -> String? { + guard let executableURL = findClaudeExecutable(environment: environment, fileManager: fileManager) else { + return nil + } + + return await runVersionCommand(executableURL: executableURL) + } + + private static func findClaudeExecutable( + environment: [String: String], + fileManager: FileManager + ) -> URL? { + let homeDirectory = fileManager.homeDirectoryForCurrentUser + var candidatePaths: [String] = [] + + if let configuredPath = environment["CLAUDE_CODE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !configuredPath.isEmpty { + candidatePaths.append(configuredPath) + } + + if let path = environment["PATH"] { + candidatePaths.append(contentsOf: path.split(separator: ":").map { directory in + URL(fileURLWithPath: String(directory)).appendingPathComponent("claude").path + }) + } + + candidatePaths.append(contentsOf: [ + homeDirectory.appendingPathComponent(".local/bin/claude").path, + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude" + ]) + + var visitedPaths = Set() + for path in candidatePaths where visitedPaths.insert(path).inserted { + guard fileManager.isExecutableFile(atPath: path) else { continue } + return URL(fileURLWithPath: path) + } + + return nil + } + + static func versionFromCommandOutput(_ output: String) -> String? { + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + !trimmed.contains(where: \.isNewline) else { + return nil + } + + let versionText = String(trimmed.prefix { !$0.isWhitespace }) + guard let version = normalizedVersion(versionText) else { return nil } + + let suffix = String(trimmed.dropFirst(versionText.count)) + guard suffix.isEmpty || suffix == " (Claude Code)" else { return nil } + return version + } + + private static func runVersionCommand(executableURL: URL) async -> String? { + await withTaskGroup(of: String?.self) { group in + let process = Process() + let pipe = Pipe() + process.executableURL = executableURL + process.arguments = ["--version"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + group.addTask { + await withCheckedContinuation { continuation in + process.terminationHandler = { _ in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + continuation.resume(returning: String(data: data, encoding: .utf8)) + } + + do { + try process.run() + } catch { + continuation.resume(returning: nil) + } + } + } + + group.addTask { + try? await Task.sleep(nanoseconds: 3_000_000_000) + return nil + } + + let output: String? + if let firstResult = await group.next() { + output = firstResult + } else { + output = nil + } + group.cancelAll() + if process.isRunning { + process.terminate() + } + return output.flatMap(versionFromCommandOutput) + } + } + + private static func normalizedVersion(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let components = trimmed.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 3, + components.allSatisfy({ component in + !component.isEmpty && component.utf8.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + }) else { + return nil + } + return trimmed } static func applyHeaders( to request: inout URLRequest, accessToken: String, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + installedVersion: String? = nil ) { + let userAgent = usageUserAgent(environment: environment, installedVersion: installedVersion) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(usageUserAgent(environment: environment), forHTTPHeaderField: "User-Agent") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta") request.setValue(nil, forHTTPHeaderField: "Cookie") request.httpShouldHandleCookies = false request.timeoutInterval = 10 logger.debug( - "Prepared Claude OAuth request headers: userAgent=\(usageUserAgent(environment: environment), privacy: .public), cookies=disabled" + "Prepared Claude OAuth request headers: userAgent=\(userAgent, privacy: .public), cookies=disabled" ) } } @@ -191,11 +321,12 @@ final class ClaudeProvider: ProviderProtocol { throw ProviderError.authenticationFailed("Anthropic access token not available") } + let installedVersion = await ClaudeOAuthRequestPolicy.installedClaudeCodeVersion() var candidates: [ClaudeAccountCandidate] = [] var fetchErrors: [Error] = [] for account in accounts { do { - let candidate = try await fetchUsageForAccount(account) + let candidate = try await fetchUsageForAccount(account, installedVersion: installedVersion) candidates.append(candidate) } catch { fetchErrors.append(error) @@ -204,7 +335,11 @@ final class ClaudeProvider: ProviderProtocol { logger.info("Skipping unavailable OpenCode Claude account") continue } - let fallback = await unavailableCandidate(for: account, error: error) + let fallback = await unavailableCandidate( + for: account, + error: error, + installedVersion: installedVersion + ) candidates.append(fallback) } } @@ -398,7 +533,10 @@ final class ClaudeProvider: ProviderProtocol { return String(full.prefix(12)) } - private func fetchAccountIdentity(accessToken: String) async -> (accountId: String?, email: String?)? { + private func fetchAccountIdentity( + accessToken: String, + installedVersion: String? + ) async -> (accountId: String?, email: String?)? { let endpoints = [ "/api/oauth/profile", "/api/oauth/account" @@ -409,7 +547,11 @@ final class ClaudeProvider: ProviderProtocol { var request = URLRequest(url: url) request.httpMethod = "GET" - ClaudeOAuthRequestPolicy.applyHeaders(to: &request, accessToken: accessToken) + ClaudeOAuthRequestPolicy.applyHeaders( + to: &request, + accessToken: accessToken, + installedVersion: installedVersion + ) do { let (data, response) = try await session.data(for: request) @@ -458,12 +600,18 @@ final class ClaudeProvider: ProviderProtocol { return nil } - private func resolveAccountIdentity(_ account: ClaudeAuthAccount) async -> ClaudeResolvedIdentity { + private func resolveAccountIdentity( + _ account: ClaudeAuthAccount, + installedVersion: String? + ) async -> ClaudeResolvedIdentity { var resolvedAccountId = normalizedNonEmpty(account.accountId) var resolvedEmail = normalizedNonEmpty(account.email, lowercase: true) if resolvedAccountId == nil || resolvedEmail == nil, - let apiIdentity = await fetchAccountIdentity(accessToken: account.accessToken) { + let apiIdentity = await fetchAccountIdentity( + accessToken: account.accessToken, + installedVersion: installedVersion + ) { if resolvedAccountId == nil { resolvedAccountId = apiIdentity.accountId } @@ -539,7 +687,10 @@ final class ClaudeProvider: ProviderProtocol { return message } - private func requestClaudeUsageData(accessToken: String) async throws -> Data { + private func requestClaudeUsageData( + accessToken: String, + installedVersion: String? + ) async throws -> Data { guard let url = claudeUsageEndpoint else { logger.error("Invalid Claude API URL") throw ProviderError.networkError("Invalid API endpoint") @@ -547,7 +698,11 @@ final class ClaudeProvider: ProviderProtocol { var request = URLRequest(url: url) request.httpMethod = "GET" - ClaudeOAuthRequestPolicy.applyHeaders(to: &request, accessToken: accessToken) + ClaudeOAuthRequestPolicy.applyHeaders( + to: &request, + accessToken: accessToken, + installedVersion: installedVersion + ) let (data, response) = try await session.data(for: request) @@ -576,10 +731,14 @@ final class ClaudeProvider: ProviderProtocol { return data } - private func unavailableCandidate(for account: ClaudeAuthAccount, error: Error) async -> ClaudeAccountCandidate { + private func unavailableCandidate( + for account: ClaudeAuthAccount, + error: Error, + installedVersion: String? + ) async -> ClaudeAccountCandidate { let sourceLabels = account.sourceLabels.isEmpty ? [sourceLabel(account.source)] : account.sourceLabels let authUsageSummary = sourceSummary(sourceLabels, fallback: "Unknown") - let identity = await resolveAccountIdentity(account) + let identity = await resolveAccountIdentity(account, installedVersion: installedVersion) logger.info( "Claude account fallback (\(authUsageSummary)): reason=\(error.localizedDescription)" @@ -603,10 +762,16 @@ final class ClaudeProvider: ProviderProtocol { ) } - private func fetchUsageForAccount(_ account: ClaudeAuthAccount) async throws -> ClaudeAccountCandidate { + private func fetchUsageForAccount( + _ account: ClaudeAuthAccount, + installedVersion: String? + ) async throws -> ClaudeAccountCandidate { // No token refresh here — refresh tokens are single-use. // If this app consumes the refresh token, OpenCode can no longer re-authenticate. - let data = try await requestClaudeUsageData(accessToken: account.accessToken) + let data = try await requestClaudeUsageData( + accessToken: account.accessToken, + installedVersion: installedVersion + ) do { let decoder = JSONDecoder() @@ -657,7 +822,7 @@ final class ClaudeProvider: ProviderProtocol { let sourceLabels = account.sourceLabels.isEmpty ? [sourceLabel(account.source)] : account.sourceLabels let authUsageSummary = sourceSummary(sourceLabels, fallback: "Unknown") - let identity = await resolveAccountIdentity(account) + let identity = await resolveAccountIdentity(account, installedVersion: installedVersion) logger.info("Claude usage fetched (\(authUsageSummary)): 7d=\(utilization)%, 5h=\(fiveHourUsage?.description ?? "N/A")%, fable7d=\(fableUsage?.description ?? "N/A", privacy: .public)%") logger.debug("Claude account identity (\(authUsageSummary)): dedupeKey=\(identity.dedupeKey), accountId=\(identity.accountId ?? "nil"), email=\(identity.email ?? "nil")") diff --git a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift index 9a52f00f..32b380bd 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift @@ -20,6 +20,46 @@ struct GeminiUserInfoResponse: Codable { let email: String? } +enum GeminiQuotaAPI { + private static let endpoint = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") + + static func fetchQuota( + accessToken: String, + projectId: String, + session: URLSession + ) async throws -> GeminiQuotaResponse { + guard let endpoint else { + throw ProviderError.networkError("Invalid API endpoint") + } + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["project": projectId]) + request.timeoutInterval = 10 + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderError.networkError("Invalid response type") + } + + if httpResponse.statusCode == 401 { + throw ProviderError.authenticationFailed("Gemini quota token expired") + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") + } + + let quotaResponse = try JSONDecoder().decode(GeminiQuotaResponse.self, from: data) + guard !quotaResponse.buckets.isEmpty else { + throw ProviderError.decodingError("Empty buckets array") + } + return quotaResponse + } +} + // MARK: - GeminiCLIProvider Implementation /// Provider for Google Gemini CLI quota tracking via cloudcode-pa.googleapis.com @@ -247,56 +287,61 @@ final class GeminiCLIProvider: ProviderProtocol { // MARK: - Private Helpers - private func fetchQuotaForAccount(account: GeminiAuthAccount) async throws -> GeminiAccountQuota { - let accountIndex = account.index - let projectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) - if projectId.isEmpty { - throw ProviderError.authenticationFailed("Missing project ID for account #\(accountIndex + 1)") - } - - guard let accessToken = await tokenManager.refreshGeminiAccessToken( - refreshToken: account.refreshToken, - clientId: account.clientId, - clientSecret: account.clientSecret - ) else { - throw ProviderError.authenticationFailed("Unable to refresh token for account #\(accountIndex + 1)") - } + private func refreshAccessToken(for account: GeminiAuthAccount) async throws -> String { + do { + return try await tokenManager.requestGeminiAccessToken( + refreshToken: account.refreshToken, + clientId: account.clientId, + clientSecret: account.clientSecret, + session: session + ) + } catch let primaryError as GeminiOAuthRefreshError { + guard GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: account.source, + error: primaryError + ) else { + throw primaryError + } - let resolvedEmail = await resolveGeminiAccountEmail(primaryEmail: account.email, accessToken: accessToken) - if resolvedEmail == "Unknown" { - logger.warning("Gemini CLI: Email lookup failed for account #\(accountIndex + 1)") - } + logger.info( + "Gemini CLI: OAuth client mismatch for account #\(account.index + 1); trying Gemini CLI client" + ) - guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") else { - throw ProviderError.networkError("Invalid API endpoint") + do { + return try await tokenManager.requestGeminiAccessToken( + refreshToken: account.refreshToken, + session: session + ) + } catch { + throw ProviderError.authenticationFailed( + "OAuth client fallback failed for account #\(account.index + 1). " + + "Primary: \(primaryError.localizedDescription). Fallback: \(error.localizedDescription)" + ) + } } + } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - // project parameter is required to get all models including gemini-3 variants - request.httpBody = "{\"project\":\"\(projectId)\"}".data(using: .utf8) - - let (data, response) = try await session.data(for: request) + private func fetchQuotaForAccount(account: GeminiAuthAccount) async throws -> GeminiAccountQuota { + let accountIndex = account.index + let configuredProjectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) + let projectId = GeminiProjectPolicy.resolve(primary: configuredProjectId) - guard let httpResponse = response as? HTTPURLResponse else { - throw ProviderError.networkError("Invalid response type") - } + let accessToken = try await refreshAccessToken(for: account) - if httpResponse.statusCode == 401 { - throw ProviderError.authenticationFailed("Token expired for account #\(accountIndex + 1)") + if configuredProjectId.isEmpty { + logger.info("Gemini CLI: Missing project ID for account #\(accountIndex + 1); using default project fallback") } - guard (200...299).contains(httpResponse.statusCode) else { - throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") + let resolvedEmail = await resolveGeminiAccountEmail(primaryEmail: account.email, accessToken: accessToken) + if resolvedEmail == "Unknown" { + logger.warning("Gemini CLI: Email lookup failed for account #\(accountIndex + 1)") } - let quotaResponse = try JSONDecoder().decode(GeminiQuotaResponse.self, from: data) - - guard !quotaResponse.buckets.isEmpty else { - throw ProviderError.decodingError("Empty buckets array") - } + let quotaResponse = try await GeminiQuotaAPI.fetchQuota( + accessToken: accessToken, + projectId: projectId, + session: session + ) var modelBreakdown: [String: Double] = [:] var modelResetTimes: [String: Date] = [:] diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index cb2beba7..28ad3ecf 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -591,7 +591,7 @@ struct AntigravityAccounts: Codable { } /// Auth source types for Gemini CLI token discovery -enum GeminiAuthSource { +enum GeminiAuthSource: Equatable { /// NoeFabris/opencode-antigravity-auth (~/.config/opencode/antigravity-accounts.json) case antigravity /// jenslys/opencode-gemini-auth (OpenCode auth.json google.oauth) @@ -614,6 +614,77 @@ struct GeminiAuthAccount { let source: GeminiAuthSource } +enum GeminiProjectPolicy { + static let fallbackProjectId = "default" + + static func resolve(primary: String?, fallback: String? = nil) -> String { + firstNonEmpty([primary, fallback]) ?? fallbackProjectId + } + + static func resolveStandalone( + projectId: String?, + quotaProjectId: String?, + environmentProjectId: String? + ) -> String { + firstNonEmpty([projectId, quotaProjectId, environmentProjectId]) ?? fallbackProjectId + } + + private static func firstNonEmpty(_ candidates: [String?]) -> String? { + candidates.lazy.compactMap { candidate in + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + }.first + } +} + +enum GeminiOAuthRefreshError: LocalizedError, Equatable { + case invalidResponse + case rejected(statusCode: Int, code: String?, message: String?) + case invalidTokenResponse + case transport(String) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid OAuth response" + case .rejected(let statusCode, let code, let message): + let detail = message ?? code ?? "Unknown OAuth error" + return "OAuth token refresh returned HTTP \(statusCode): \(detail)" + case .invalidTokenResponse: + return "OAuth token response did not contain a valid access token" + case .transport(let message): + return "OAuth token refresh failed: \(message)" + } + } + + var isClientMismatch: Bool { + guard case .rejected(let statusCode, let code, _) = self, + let normalizedCode = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return false + } + + switch (statusCode, normalizedCode) { + case (400, "invalid_client"), + (400, "unauthorized_client"), + (401, "invalid_client"), + (401, "unauthorized_client"), + (401, "unauthorized"): + return true + default: + return false + } + } +} + +enum GeminiOAuthRetryPolicy { + static func shouldRetryWithGeminiCLIClient( + source: GeminiAuthSource, + error: GeminiOAuthRefreshError + ) -> Bool { + source == .opencodeAuth && error.isClientMismatch + } +} + /// Minimal OpenCode auth payload for jenslys/opencode-gemini-auth stored under "google" struct OpenCodeGeminiAuthContainer: Decodable { let google: GeminiOAuthAuth? @@ -753,6 +824,16 @@ struct GeminiTokenResponse: Codable { let token_type: String? } +private struct GeminiOAuthErrorResponse: Decodable { + let error: String? + let errorDescription: String? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + } +} + private extension String { var nilIfEmpty: String? { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) @@ -4275,6 +4356,35 @@ final class TokenManager: @unchecked Sendable { return getAllGeminiAccounts().first?.email } + func standaloneGeminiAccount( + from oauthCreds: GeminiOAuthCreds, + authSource: String, + environmentProjectId: String? + ) -> GeminiAuthAccount? { + guard let refreshToken = normalizedNonEmpty(oauthCreds.refreshToken) else { + return nil + } + + let identity = decodeGeminiIDTokenPayload(oauthCreds.idToken) + let client = geminiOAuthClientCredentials(for: normalizedNonEmpty(identity?.audience)) + return GeminiAuthAccount( + index: 0, + accountId: normalizedNonEmpty(identity?.sub), + email: normalizedNonEmpty(identity?.email), + refreshToken: refreshToken, + projectId: GeminiProjectPolicy.resolveStandalone( + projectId: oauthCreds.projectId, + quotaProjectId: oauthCreds.quotaProjectId, + environmentProjectId: environmentProjectId + ), + authSource: authSource, + sourceLabels: [geminiSourceLabel(for: .oauthCreds)], + clientId: client.clientId, + clientSecret: client.clientSecret, + source: .oauthCreds + ) + } + /// Gets all Gemini accounts (NoeFabris/opencode-antigravity-auth + jenslys/opencode-gemini-auth) /// and enriches account identity metadata from ~/.gemini/oauth_creds.json when available. func getAllGeminiAccounts() -> [GeminiAuthAccount] { @@ -4285,7 +4395,6 @@ final class TokenManager: @unchecked Sendable { let oauthCredsEmail = normalizedNonEmpty(oauthCredsPayload?.email) let oauthCredsRefreshToken = normalizedNonEmpty(oauthCreds?.refreshToken) let oauthCredsAudience = normalizedNonEmpty(oauthCredsPayload?.audience) - let oauthCredsClient = geminiOAuthClientCredentials(for: oauthCredsAudience) let geminiOAuthCredsSource = lastFoundGeminiOAuthCredsPath?.path ?? geminiOAuthCredsPath().path if oauthCreds != nil { @@ -4342,12 +4451,12 @@ final class TokenManager: @unchecked Sendable { return nil } - let primaryProjectId = account.projectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let fallbackProjectId = account.managedProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let projectId = primaryProjectId.isEmpty ? fallbackProjectId : primaryProjectId - if projectId.isEmpty { - logger.warning("Skipping Antigravity account at index \(index): missing project ID") - return nil + let projectId = GeminiProjectPolicy.resolve( + primary: account.projectId, + fallback: account.managedProjectId + ) + if projectId == GeminiProjectPolicy.fallbackProjectId { + logger.info("Antigravity account at index \(index) has no project ID; using default project fallback") } let normalizedEmail = normalizedNonEmpty(account.email) @@ -4390,30 +4499,17 @@ final class TokenManager: @unchecked Sendable { } if accounts.isEmpty, - let refreshToken = oauthCredsRefreshToken { - let standaloneProjectId = normalizedNonEmpty(oauthCreds?.projectId) - ?? normalizedNonEmpty(oauthCreds?.quotaProjectId) - ?? normalizedNonEmpty(ProcessInfo.processInfo.environment["GEMINI_PROJECT_ID"]) - - if let projectId = standaloneProjectId { - accounts.append( - GeminiAuthAccount( - index: 0, - accountId: oauthCredsAccountId, - email: oauthCredsEmail, - refreshToken: refreshToken, - projectId: projectId, - authSource: geminiOAuthCredsSource, - sourceLabels: [geminiSourceLabel(for: .oauthCreds)], - clientId: oauthCredsClient.clientId, - clientSecret: oauthCredsClient.clientSecret, - source: .oauthCreds - ) - ) - logger.info("Gemini oauth_creds.json used as standalone account source") - } else { - logger.info("Gemini oauth_creds.json found but project ID is missing; skipping standalone account source") + let oauthCreds, + let standaloneAccount = standaloneGeminiAccount( + from: oauthCreds, + authSource: geminiOAuthCredsSource, + environmentProjectId: ProcessInfo.processInfo.environment["GEMINI_PROJECT_ID"] + ) { + accounts.append(standaloneAccount) + if standaloneAccount.projectId == GeminiProjectPolicy.fallbackProjectId { + logger.info("Gemini oauth_creds.json has no project ID; using default project fallback") } + logger.info("Gemini oauth_creds.json used as standalone account source") } if accounts.isEmpty { @@ -4453,22 +4549,23 @@ final class TokenManager: @unchecked Sendable { private static let geminiAuthPluginClientId = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" private static let geminiAuthPluginClientSecret = "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" - /// Refreshes Gemini OAuth access token using refresh token + /// Requests a Gemini OAuth access token and preserves typed OAuth failures. /// - Parameters: /// - refreshToken: The refresh token from Antigravity accounts /// - clientId: Google OAuth client ID (default: public CLI client ID) /// - clientSecret: Google OAuth client secret (default: public CLI client secret) - /// - Returns: New access token if successful, nil otherwise - func refreshGeminiAccessToken( + /// - session: URL session used for the token request + /// - Returns: New access token if successful + func requestGeminiAccessToken( refreshToken: String, clientId: String = TokenManager.geminiClientId, - clientSecret: String = TokenManager.geminiClientSecret - ) async -> String? { + clientSecret: String = TokenManager.geminiClientSecret, + session: URLSession = .shared + ) async throws -> String { let endpoint = "https://oauth2.googleapis.com/token" guard let url = URL(string: endpoint) else { - logger.error("Invalid OAuth endpoint URL") - return nil + throw GeminiOAuthRefreshError.invalidResponse } // Build request body @@ -4481,8 +4578,7 @@ final class TokenManager: @unchecked Sendable { ] guard let bodyString = components.query else { - logger.error("Failed to build request body") - return nil + throw GeminiOAuthRefreshError.invalidResponse } var request = URLRequest(url: url) @@ -4491,21 +4587,49 @@ final class TokenManager: @unchecked Sendable { request.httpBody = bodyString.data(using: .utf8) do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - logger.error("Invalid response type") - return nil + throw GeminiOAuthRefreshError.invalidResponse } guard httpResponse.statusCode == 200 else { - logger.error("OAuth token refresh failed with status: \(httpResponse.statusCode)") - return nil + let payload = try? JSONDecoder().decode(GeminiOAuthErrorResponse.self, from: data) + throw GeminiOAuthRefreshError.rejected( + statusCode: httpResponse.statusCode, + code: payload?.error, + message: payload?.errorDescription + ) + } + + guard let tokenResponse = try? JSONDecoder().decode(GeminiTokenResponse.self, from: data), + !tokenResponse.access_token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw GeminiOAuthRefreshError.invalidTokenResponse } - let tokenResponse = try JSONDecoder().decode(GeminiTokenResponse.self, from: data) - logger.info("Successfully refreshed Gemini access token") return tokenResponse.access_token + } catch let error as GeminiOAuthRefreshError { + throw error + } catch { + throw GeminiOAuthRefreshError.transport(error.localizedDescription) + } + } + + /// Refreshes Gemini OAuth access token using refresh token. + /// - Returns: New access token if successful, nil otherwise + func refreshGeminiAccessToken( + refreshToken: String, + clientId: String = TokenManager.geminiClientId, + clientSecret: String = TokenManager.geminiClientSecret + ) async -> String? { + do { + let accessToken = try await requestGeminiAccessToken( + refreshToken: refreshToken, + clientId: clientId, + clientSecret: clientSecret + ) + logger.info("Successfully refreshed Gemini access token") + return accessToken } catch { logger.error("Failed to refresh Gemini token: \(error.localizedDescription)") return nil diff --git a/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift b/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift index 526e445c..f8b9c9c4 100644 --- a/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/AntigravityProviderVarintTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import CopilotMonitor +@testable import OpenCode_Bar final class AntigravityProviderVarintTests: XCTestCase { private let provider = AntigravityProvider() @@ -65,6 +65,33 @@ final class AntigravityProviderVarintTests: XCTestCase { XCTAssertThrowsError(try provider.parseProtobufMessage(payload)) } + func testFallbackAccountSelectionSkipsStalePreferredAccount() throws { + let staleAccount = AntigravityAccounts.Account( + email: "stale@example.com", + refreshToken: " ", + projectId: nil, + managedProjectId: nil, + enabled: true + ) + let validAccount = AntigravityAccounts.Account( + email: "valid@example.com", + refreshToken: "valid-refresh-token", + projectId: nil, + managedProjectId: nil, + enabled: true + ) + let accounts = AntigravityAccounts( + version: 4, + accounts: [staleAccount, validAccount], + activeIndex: 0, + activeIndexByFamily: ["gemini": 0] + ) + + let selected = try XCTUnwrap(AntigravityProvider.selectFallbackAccount(from: accounts)) + XCTAssertEqual(selected.email, "valid@example.com") + XCTAssertEqual(selected.refreshToken, "valid-refresh-token") + } + private func assertVarintRoundTrip(_ expected: UInt64) throws { let encoded = encodeVarint(expected) var index = 0 diff --git a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift index bd9ca227..50bc49e5 100644 --- a/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ClaudeProviderTests.swift @@ -136,6 +136,47 @@ final class ClaudeProviderTests: XCTestCase { XCTAssertEqual(userAgent, "claude-code/3.0.0") } + + func testClaudeOAuthRequestPolicyUsesInstalledVersionForUserAgent() { + let userAgent = ClaudeOAuthRequestPolicy.usageUserAgent( + environment: [:], + installedVersion: "2.1.199" + ) + + XCTAssertEqual(userAgent, "claude-code/2.1.199") + } + + func testClaudeOAuthRequestPolicyRejectsInvalidVersionOverride() { + let userAgent = ClaudeOAuthRequestPolicy.usageUserAgent( + environment: ["ANTHROPIC_CLI_VERSION": "invalid"], + installedVersion: "2.1.199" + ) + + XCTAssertEqual(userAgent, "claude-code/2.1.199") + } + + func testClaudeOAuthRequestPolicyParsesOfficialVersionOutput() { + XCTAssertEqual( + ClaudeOAuthRequestPolicy.versionFromCommandOutput("2.1.199 (Claude Code)\n"), + "2.1.199" + ) + } + + func testClaudeOAuthRequestPolicyRejectsPrefixedVersionOutput() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("Claude Code 2.1.199")) + } + + func testClaudeOAuthRequestPolicyRejectsMultilineVersionOutput() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("2.1.199\nunexpected")) + } + + func testClaudeOAuthRequestPolicyRejectsPrereleaseVersionOutput() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("2.1.199-beta")) + } + + func testClaudeOAuthRequestPolicyRejectsUnicodeDigits() { + XCTAssertNil(ClaudeOAuthRequestPolicy.versionFromCommandOutput("٢.١.١٩٩")) + } private func loadFixture(named: String) -> Data { let bundle = Bundle(for: type(of: self)) diff --git a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift index 28527cc9..8d22d188 100644 --- a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift @@ -2,6 +2,66 @@ import XCTest @testable import OpenCode_Bar final class GeminiCLIProviderTests: XCTestCase { + private final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } + + private static func requestBodyData(from request: URLRequest) -> Data? { + if let body = request.httpBody { + return body + } + + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { return nil } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + super.tearDown() + } func testParseGeminiQuotaResponse() throws { let fixture = try loadFixture(named: "gemini_response") @@ -61,6 +121,187 @@ final class GeminiCLIProviderTests: XCTestCase { let earliestReset = resetDates.min() XCTAssertNotNil(earliestReset) } + + func testStandaloneOAuthCredentialsWithoutProjectReachDefaultQuotaRequest() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + let fixture = try loadFixture(named: "gemini_response") + let responseData = try JSONSerialization.data(withJSONObject: fixture) + let credentials = try JSONDecoder().decode( + GeminiOAuthCreds.self, + from: Data(#"{"refresh_token":"standalone-refresh-token"}"#.utf8) + ) + let account = try XCTUnwrap( + TokenManager.shared.standaloneGeminiAccount( + from: credentials, + authSource: "/tmp/oauth_creds.json", + environmentProjectId: nil + ) + ) + + XCTAssertEqual(account.refreshToken, "standalone-refresh-token") + XCTAssertEqual(account.projectId, "default") + XCTAssertEqual(account.source, .oauthCreds) + + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-access-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.timeoutInterval, 10, accuracy: 0.001) + + let requestData = try XCTUnwrap(Self.requestBodyData(from: request)) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: requestData) as? [String: String] + ) + XCTAssertEqual(payload, ["project": "default"]) + + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + ) + return (response, responseData) + } + + let response = try await GeminiQuotaAPI.fetchQuota( + accessToken: "test-access-token", + projectId: account.projectId, + session: session + ) + + XCTAssertEqual(response.buckets.count, 5) + XCTAssertEqual(response.buckets.last?.modelId, "gemini-3-pro-preview") + } + + func testOAuthRefreshPreservesInvalidGrantWithoutRetry() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil) + ) + let data = Data(#"{"error":"invalid_grant","error_description":"Token expired"}"#.utf8) + return (response, data) + } + + do { + _ = try await TokenManager.shared.requestGeminiAccessToken( + refreshToken: "test-refresh-token", + clientId: "account-client", + clientSecret: "account-secret", + session: session + ) + XCTFail("Expected typed OAuth rejection") + } catch let error as GeminiOAuthRefreshError { + XCTAssertEqual( + error, + .rejected(statusCode: 400, code: "invalid_grant", message: "Token expired") + ) + XCTAssertFalse( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: error + ) + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testOAuthRefreshClassifiesOpenCodeClientMismatchForRetry() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil) + ) + return (response, Data(#"{"error":"Unauthorized"}"#.utf8)) + } + + do { + _ = try await TokenManager.shared.requestGeminiAccessToken( + refreshToken: "test-refresh-token", + clientId: "opencode-plugin-client", + clientSecret: "opencode-plugin-secret", + session: session + ) + XCTFail("Expected typed OAuth rejection") + } catch let error as GeminiOAuthRefreshError { + XCTAssertTrue(error.isClientMismatch) + XCTAssertTrue( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: error + ) + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testOAuthRefreshClassifiesHTTP400ClientMismatchForRetry() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = try XCTUnwrap( + HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil) + ) + return (response, Data(#"{"error":"invalid_client"}"#.utf8)) + } + + do { + _ = try await TokenManager.shared.requestGeminiAccessToken( + refreshToken: "test-refresh-token", + clientId: "opencode-plugin-client", + clientSecret: "opencode-plugin-secret", + session: session + ) + XCTFail("Expected typed OAuth rejection") + } catch let error as GeminiOAuthRefreshError { + XCTAssertTrue(error.isClientMismatch) + XCTAssertTrue( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: error + ) + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testOAuthRetryPolicyAllowsOnlyOpenCodeClientMismatch() { + let mismatch = GeminiOAuthRefreshError.rejected( + statusCode: 401, + code: "unauthorized_client", + message: nil + ) + + XCTAssertTrue( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .opencodeAuth, + error: mismatch + ) + ) + XCTAssertFalse( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .antigravity, + error: mismatch + ) + ) + XCTAssertFalse( + GeminiOAuthRetryPolicy.shouldRetryWithGeminiCLIClient( + source: .oauthCreds, + error: mismatch + ) + ) + } private func loadFixture(named: String) throws -> Any { let testBundle = Bundle(for: type(of: self)) diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index 97db21c5..72ea9ea9 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -2,6 +2,45 @@ import XCTest @testable import OpenCode_Bar final class TokenManagerTests: XCTestCase { + func testGeminiProjectPolicyPrefersConfiguredProject() { + XCTAssertEqual( + GeminiProjectPolicy.resolve(primary: "configured-project", fallback: "managed-project"), + "configured-project" + ) + } + + func testGeminiProjectPolicyUsesManagedProjectFallback() { + XCTAssertEqual( + GeminiProjectPolicy.resolve(primary: "", fallback: "managed-project"), + "managed-project" + ) + } + + func testGeminiProjectPolicyUsesDefaultWhenProjectIsMissing() { + XCTAssertEqual(GeminiProjectPolicy.resolve(primary: nil), "default") + } + + func testGeminiStandaloneProjectPolicyUsesDefaultWhenMetadataIsMissing() { + XCTAssertEqual( + GeminiProjectPolicy.resolveStandalone( + projectId: nil, + quotaProjectId: nil, + environmentProjectId: nil + ), + "default" + ) + } + + func testGeminiStandaloneProjectPolicyPrefersEnvironmentBeforeDefault() { + XCTAssertEqual( + GeminiProjectPolicy.resolveStandalone( + projectId: nil, + quotaProjectId: nil, + environmentProjectId: "environment-project" + ), + "environment-project" + ) + } private func makeTestJWT(payload: String) -> String { func encode(_ string: String) -> String { diff --git a/docs/AI_USAGE_API_REFERENCE.md b/docs/AI_USAGE_API_REFERENCE.md index f8a38d47..f77ac2f2 100644 --- a/docs/AI_USAGE_API_REFERENCE.md +++ b/docs/AI_USAGE_API_REFERENCE.md @@ -20,11 +20,12 @@ **Endpoint:** `GET https://api.anthropic.com/api/oauth/usage` -Latest Claude Code-compatible usage requests use Bearer OAuth with `anthropic-beta: oauth-2025-04-20`, a Claude Code `User-Agent` (`claude-code/`), and no browser cookies. +Latest Claude Code-compatible usage requests use Bearer OAuth with `anthropic-beta: oauth-2025-04-20`, a Claude Code `User-Agent` (`claude-code/`), and no browser cookies. OpenCode Bar locates the Claude executable through `CLAUDE_CODE_PATH`, `PATH`, or common installation paths and executes `claude --version`. Only exact `X.Y.Z` or `X.Y.Z (Claude Code)` output is accepted. `ANTHROPIC_CLI_VERSION` can provide a validated `X.Y.Z` override; otherwise invalid or unavailable version output falls back to `2.1.80`. ```bash ACCESS=$(jq -r '.anthropic.access' ~/.local/share/opencode/auth.json) -CLAUDE_CODE_VERSION="${ANTHROPIC_CLI_VERSION:-2.1.80}" +source ./scripts/claude-version.sh +CLAUDE_CODE_VERSION="$(resolve_claude_code_version)" curl -s "https://api.anthropic.com/api/oauth/usage" \ -H "Authorization: Bearer $ACCESS" \ @@ -34,7 +35,7 @@ curl -s "https://api.anthropic.com/api/oauth/usage" \ -H "anthropic-beta: oauth-2025-04-20" ``` -The bundled [`scripts/query-claude.sh`](/Users/kargnas/projects/opencode-bar/scripts/query-claude.sh) now resolves Claude auth in this order: +The bundled [`scripts/query-claude.sh`](../scripts/query-claude.sh) uses the same version policy and resolves Claude auth in this order: 1. `opencode-anthropic-auth/accounts.json` 2. OpenCode `auth.json` diff --git a/scripts/claude-version.sh b/scripts/claude-version.sh new file mode 100644 index 00000000..4d71bfd3 --- /dev/null +++ b/scripts/claude-version.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Shared Claude Code version policy for shell diagnostics. + +CLAUDE_DEFAULT_CODE_VERSION="2.1.80" + +normalize_claude_code_version() { + local raw="${1:-}" + raw="${raw#"${raw%%[![:space:]]*}"}" + raw="${raw%"${raw##*[![:space:]]}"}" + + [[ -n "$raw" ]] || return 1 + [[ "$raw" != *$'\n'* && "$raw" != *$'\r'* ]] || return 1 + + if [[ "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(\ \(Claude\ Code\))?$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + + return 1 +} + +detect_claude_code_version() { + local claude_bin="" + local output="" + claude_bin="$(command -v claude 2>/dev/null || true)" + [[ -n "$claude_bin" ]] || return 1 + + output="$("$claude_bin" --version 2>/dev/null)" || return 1 + normalize_claude_code_version "$output" +} + +resolve_claude_code_version() { + local override="${ANTHROPIC_CLI_VERSION:-}" + local version="" + + if version="$(normalize_claude_code_version "$override")"; then + printf '%s\n' "$version" + return 0 + fi + + if version="$(detect_claude_code_version)"; then + printf '%s\n' "$version" + return 0 + fi + + printf '%s\n' "$CLAUDE_DEFAULT_CODE_VERSION" +} diff --git a/scripts/query-claude.sh b/scripts/query-claude.sh index 7a778ead..d66b8d34 100755 --- a/scripts/query-claude.sh +++ b/scripts/query-claude.sh @@ -3,9 +3,12 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/claude-version.sh" + CLAUDE_USAGE_URL="https://api.anthropic.com/api/oauth/usage" CLAUDE_BETA_HEADER="${ANTHROPIC_OAUTH_BETA:-oauth-2025-04-20}" -CLAUDE_CODE_VERSION="${ANTHROPIC_CLI_VERSION:-2.1.80}" +CLAUDE_CODE_VERSION="$(resolve_claude_code_version)" CLAUDE_USER_AGENT="${ANTHROPIC_CODE_USER_AGENT:-claude-code/${CLAUDE_CODE_VERSION}}" AUTH_SOURCE="" diff --git a/scripts/tests/query-claude-version-test.sh b/scripts/tests/query-claude-version-test.sh new file mode 100755 index 00000000..57f60e51 --- /dev/null +++ b/scripts/tests/query-claude-version-test.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Contract tests for Claude Code version detection used by query-claude.sh. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +source "$REPO_ROOT/scripts/claude-version.sh" + +TEMP_DIR="$(mktemp -d)" +FAKE_BIN="$TEMP_DIR/bin" +mkdir -p "$FAKE_BIN" +trap 'rm -rf "$TEMP_DIR"' EXIT + +write_fake_claude() { + local command_body="$1" + printf '%s\n%s\n' '#!/bin/bash' "$command_body" > "$FAKE_BIN/claude" + chmod +x "$FAKE_BIN/claude" +} + +assert_version() { + local expected="$1" + local description="$2" + local actual="" + actual="$(PATH="$FAKE_BIN:/usr/bin:/bin" resolve_claude_code_version)" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $description (expected $expected, got $actual)" >&2 + exit 1 + fi +} + +unset ANTHROPIC_CLI_VERSION + +write_fake_claude 'printf "%s\n" "2.1.199 (Claude Code)"' +assert_version "2.1.199" "official Claude Code output" + +write_fake_claude 'printf "%s\n" "Claude Code 2.1.199"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "prefixed output fallback" + +write_fake_claude 'printf "2.1.199\nunexpected\n"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "multiline output fallback" + +write_fake_claude 'printf "%s\n" "2.1.199-beta"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "prerelease output fallback" + +write_fake_claude 'printf "%s\n" "٢.١.١٩٩"' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "Unicode digit output fallback" + +write_fake_claude 'exit 1' +assert_version "$CLAUDE_DEFAULT_CODE_VERSION" "command failure fallback" + +write_fake_claude 'printf "%s\n" "2.1.199 (Claude Code)"' +ANTHROPIC_CLI_VERSION="invalid" assert_version "2.1.199" "invalid override fallback" +ANTHROPIC_CLI_VERSION="3.0.0" assert_version "3.0.0" "valid override" + +echo "Claude version shell tests passed"