diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index 1e7d12f2..3949f5ea 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ OCAUTHTESTBF11111111111 /* OpenCodeAuthDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */; }; OCZENTESTBF111111111111 /* OpenCodeZenProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCZENTESTFR111111111111 /* OpenCodeZenProviderTests.swift */; }; CLAUDETESTBF11111111111 /* ClaudeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CLAUDETESTFR11111111111 /* ClaudeProviderTests.swift */; }; + ZAITESTBF11111111111111 /* ZaiCodingPlanProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ZAITESTFR11111111111111 /* ZaiCodingPlanProviderTests.swift */; }; ME1111111111111111111111 /* MenuEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = ME2222222222222222222222 /* MenuEnums.swift */; }; OC1111111111111111111111 /* OpenCodeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OC2222222222222222222222 /* OpenCodeProvider.swift */; }; OR1111111111111111111111 /* OpenRouterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OR2222222222222222222222 /* OpenRouterProvider.swift */; }; @@ -236,6 +237,7 @@ OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeAuthDecodingTests.swift; sourceTree = ""; }; OCZENTESTFR111111111111 /* OpenCodeZenProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeZenProviderTests.swift; sourceTree = ""; }; CLAUDETESTFR11111111111 /* ClaudeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProviderTests.swift; sourceTree = ""; }; + ZAITESTFR11111111111111 /* ZaiCodingPlanProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZaiCodingPlanProviderTests.swift; sourceTree = ""; }; TDDDDDDDDDDDDDDDDDDDDDD /* CopilotMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CopilotMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -463,6 +465,7 @@ OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */, OCZENTESTFR111111111111 /* OpenCodeZenProviderTests.swift */, CLAUDETESTFR11111111111 /* ClaudeProviderTests.swift */, + ZAITESTFR11111111111111 /* ZaiCodingPlanProviderTests.swift */, ); path = CopilotMonitorTests; sourceTree = ""; @@ -722,6 +725,7 @@ OCAUTHTESTBF11111111111 /* OpenCodeAuthDecodingTests.swift in Sources */, OCZENTESTBF111111111111 /* OpenCodeZenProviderTests.swift in Sources */, CLAUDETESTBF11111111111 /* ClaudeProviderTests.swift in Sources */, + ZAITESTBF11111111111111 /* ZaiCodingPlanProviderTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift index 8f002e43..d14b31d1 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift @@ -186,7 +186,7 @@ final class CodexProvider: ProviderProtocol { let credits: CreditsInfo? } - private struct SelfServiceUsageResponse: Decodable { + struct SelfServiceUsageResponse: Decodable { let requestCount: Int? let totalTokens: Int? let cachedInputTokens: Int? @@ -199,6 +199,7 @@ final class CodexProvider: ProviderProtocol { case cachedInputTokens = "cached_input_tokens" case totalCostUSD = "total_cost_usd" case limits + case upstreamLimits = "upstream_limits" } init(from decoder: Decoder) throws { @@ -207,11 +208,41 @@ final class CodexProvider: ProviderProtocol { totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) cachedInputTokens = try container.decodeIfPresent(Int.self, forKey: .cachedInputTokens) totalCostUSD = try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) - limits = (try? container.decodeIfPresent([SelfServiceLimit].self, forKey: .limits)) ?? [] + let localLimits = (try? container.decodeIfPresent([SelfServiceLimit].self, forKey: .limits)) ?? [] + let upstreamLimits = (try? container.decodeIfPresent([SelfServiceLimit].self, forKey: .upstreamLimits)) ?? [] + let selectedLimits = upstreamLimits.isEmpty ? localLimits : upstreamLimits + let uniqueLimits = Self.deduplicate(selectedLimits) + limits = uniqueLimits + + logger.debug( + "Codex self-service selected \(upstreamLimits.isEmpty ? "local" : "upstream") limits: \(selectedLimits.count) input, \(uniqueLimits.count) unique" + ) + } + + private static func deduplicate(_ limits: [SelfServiceLimit]) -> [SelfServiceLimit] { + var seen = Set() + // The selected source is either the canonical account-level upstream list + // or the local fallback list. Preserve its JSON order so the first entry + // remains the service-provided primary value for a duplicate window. + return limits.filter { limit in + seen.insert(LimitKey(limit: limit)).inserted + } + } + + private struct LimitKey: Hashable { + let limitWindow: String? + let modelFilter: String? + let limitType: String? + + init(limit: SelfServiceLimit) { + limitWindow = limit.limitWindow + modelFilter = limit.modelFilter + limitType = limit.limitType + } } } - private struct SelfServiceLimit: Decodable { + struct SelfServiceLimit: Decodable { let limitType: String? let limitWindow: String? let maxValue: Double? @@ -400,7 +431,7 @@ final class CodexProvider: ProviderProtocol { return merged } - private func sourceSummary(_ labels: [String], fallback: String) -> String { + func sourceSummary(_ labels: [String], fallback: String) -> String { let merged = mergeSourceLabels(labels, []) if merged.isEmpty { return fallback diff --git a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift index 9a52f00f..0b1bb030 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift @@ -249,16 +249,24 @@ final class GeminiCLIProvider: ProviderProtocol { private func fetchQuotaForAccount(account: GeminiAuthAccount) async throws -> GeminiAccountQuota { let accountIndex = account.index - let projectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) - if projectId.isEmpty { + let configuredProjectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !configuredProjectId.isEmpty else { + logger.error("Gemini CLI: Missing project ID for account #\(accountIndex + 1); quota fetch rejected") throw ProviderError.authenticationFailed("Missing project ID for account #\(accountIndex + 1)") } - guard let accessToken = await tokenManager.refreshGeminiAccessToken( + var refreshedAccessToken = await tokenManager.refreshGeminiAccessToken( refreshToken: account.refreshToken, clientId: account.clientId, clientSecret: account.clientSecret - ) else { + ) + if refreshedAccessToken == nil, + Self.shouldRetryWithGeminiCLIClient(primaryClientID: account.clientId) { + logger.info("Gemini CLI: Primary OAuth client failed for account #\(accountIndex + 1); trying Gemini CLI OAuth client fallback") + refreshedAccessToken = await tokenManager.refreshGeminiAccessToken(refreshToken: account.refreshToken) + } + + guard let accessToken = refreshedAccessToken else { throw ProviderError.authenticationFailed("Unable to refresh token for account #\(accountIndex + 1)") } @@ -275,8 +283,8 @@ final class GeminiCLIProvider: ProviderProtocol { 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) + // project parameter is required to get all models including gemini-3 variants. + request.httpBody = "{\"project\":\"\(configuredProjectId)\"}".data(using: .utf8) let (data, response) = try await session.data(for: request) @@ -343,6 +351,10 @@ final class GeminiCLIProvider: ProviderProtocol { ) } + static func shouldRetryWithGeminiCLIClient(primaryClientID: String) -> Bool { + primaryClientID != TokenManager.geminiClientId + } + private func resolveGeminiAccountEmail(primaryEmail: String?, accessToken: String) async -> String { if let email = primaryEmail?.trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty { return email diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift index b6d09c25..2a1e0385 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeZenProvider.swift @@ -289,6 +289,7 @@ final class OpenCodeZenProvider: ProviderProtocol { listing.standardError = FileHandle.nullDevice do { + debugLog("Stale cleanup: listing 'opencode stats' processes") try listing.run() } catch { debugLog("Stale cleanup: failed to list processes: \(error.localizedDescription)") @@ -304,6 +305,11 @@ final class OpenCodeZenProvider: ProviderProtocol { let data = pipe.fileHandleForReading.readDataToEndOfFile() listing.waitUntilExit() + guard listing.terminationStatus == 0 else { + debugLog("Stale cleanup: process listing exited with code \(listing.terminationStatus)") + return + } + guard let output = String(data: data, encoding: .utf8) else { return } let selfPid = ProcessInfo.processInfo.processIdentifier diff --git a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index 97ba0dfa..0275236d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -155,6 +155,7 @@ private struct ZaiToolUsageTotals: Decodable { final class ZaiCodingPlanProvider: ProviderProtocol { let identifier: ProviderIdentifier = .zaiCodingPlan let type: ProviderType = .quotaBased + let fetchTimeout: TimeInterval = 30.0 private let tokenManager: TokenManager private let session: URLSession @@ -288,6 +289,30 @@ final class ZaiCodingPlanProvider: ProviderProtocol { } private func fetchData(url: URL, apiKey: String) async throws -> Data { + let maxAttempts = 3 + var lastError: Error? + + for attempt in 1...maxAttempts { + do { + return try await fetchDataOnce(url: url, apiKey: apiKey) + } catch { + lastError = error + + guard attempt < maxAttempts, Self.isTransientNetworkError(error) else { + throw error + } + + logger.warning("Z.AI Coding Plan request failed with transient error on attempt \(attempt)/\(maxAttempts): \(error.localizedDescription)") + let retryDelay = Self.retryDelayNanoseconds(for: attempt) + logger.debug("Z.AI Coding Plan retry \(attempt)/\(maxAttempts) scheduled after \(retryDelay)ns") + try await Task.sleep(nanoseconds: retryDelay) + } + } + + throw lastError ?? ProviderError.networkError("Z.AI Coding Plan request failed") + } + + private func fetchDataOnce(url: URL, apiKey: String) async throws -> Data { var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(apiKey, forHTTPHeaderField: "Authorization") @@ -310,6 +335,63 @@ final class ZaiCodingPlanProvider: ProviderProtocol { return data } + static func retryDelayNanoseconds(for attempt: Int, jitter: UInt64 = UInt64.random(in: 0...250_000_000)) -> UInt64 { + UInt64(attempt) * 500_000_000 + jitter + } + + static func isTransientNetworkError(_ error: Error) -> Bool { + if isTransientURLError(error as NSError) { + return true + } + + if let providerError = error as? ProviderError { + switch providerError { + case .networkError(let message): + return message.contains("HTTP 5") || message.localizedCaseInsensitiveContains("tls") + default: + return false + } + } + + return false + } + + private static func isTransientURLError(_ error: NSError) -> Bool { + var currentError: NSError? = error + + for _ in 0..<8 { + guard let current = currentError else { return false } + + if current.domain == NSURLErrorDomain, + isTransientURLErrorCode(current.code) { + return true + } + + currentError = current.userInfo[NSUnderlyingErrorKey] as? NSError + } + + return false + } + + private static func isTransientURLErrorCode(_ code: Int) -> Bool { + switch code { + case NSURLErrorNetworkConnectionLost, + NSURLErrorTimedOut, + NSURLErrorNotConnectedToInternet, + NSURLErrorCannotConnectToHost, + NSURLErrorCannotFindHost, + NSURLErrorDNSLookupFailed, + NSURLErrorSecureConnectionFailed, + NSURLErrorServerCertificateHasBadDate, + NSURLErrorServerCertificateUntrusted, + NSURLErrorServerCertificateHasUnknownRoot, + NSURLErrorServerCertificateNotYetValid: + return true + default: + return false + } + } + private func decodeResponse(_ type: T.Type, from data: Data) throws -> T { let decoder = JSONDecoder() if let envelope = try? decoder.decode(ZaiEnvelope.self, from: data), let payload = envelope.data { diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index cb2beba7..ed6c014c 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -1214,6 +1214,18 @@ final class TokenManager: @unchecked Sendable { ) } + func getOpenAIProviderAPIKeyWithSource() -> (key: String, source: String)? { + let config = readOpenCodeConfigJSON() + guard let config, + let apiKey = resolveConfigValue( + nestedString(in: config, path: ["provider", "openai", "options", "apiKey"]) + ) else { + return nil + } + + return (apiKey, lastFoundOpenCodeConfigPath?.path ?? "provider.openai.options.apiKey") + } + private struct SearchAPIKeyLookupSource { let dictionary: [String: Any]? let sourcePath: String? @@ -3648,6 +3660,21 @@ final class TokenManager: @unchecked Sendable { func getOpenAIAccounts() -> [OpenAIAuthAccount] { var accounts: [OpenAIAuthAccount] = [] + if let configuredAPIKey = getOpenAIProviderAPIKeyWithSource() { + accounts.append( + OpenAIAuthAccount( + accessToken: configuredAPIKey.key, + accountId: nil, + externalUsageAccountId: nil, + email: nil, + authSource: configuredAPIKey.source, + sourceLabels: ["OpenCode Config (API Key)"], + source: .opencodeAuth, + credentialType: .apiKey + ) + ) + } + if let auth = readOpenCodeAuth(), let access = auth.openai?.access, !access.isEmpty { @@ -4446,7 +4473,7 @@ final class TokenManager: @unchecked Sendable { /// Public Google OAuth client credentials for CLI/installed apps /// These are NOT secrets - they are public client IDs/secrets for installed applications /// See: https://developers.google.com/identity/protocols/oauth2/native-app - private static let geminiClientId = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + static let geminiClientId = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" private static let geminiClientSecret = "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" /// OAuth client used by jenslys/opencode-gemini-auth plugin diff --git a/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift index fc4f1ffe..689aa407 100644 --- a/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift @@ -22,6 +22,57 @@ final class CodexProviderTests: XCTestCase { func testProviderType() { XCTAssertEqual(provider.type, .quotaBased) } + + func testSelfServiceUsagePrefersAndDeduplicatesUpstreamLimits() throws { + let json = #""" + { + "limits": [ + { + "limit_type": "requests", + "limit_window": "168h", + "max_value": 100, + "current_value": 90 + } + ], + "upstream_limits": [ + { + "limit_type": "requests", + "limit_window": "5h", + "max_value": 100, + "current_value": 10 + }, + { + "limit_type": "requests", + "limit_window": "5h", + "max_value": 100, + "current_value": 20 + }, + { + "limit_type": "requests", + "limit_window": "168h", + "max_value": 100, + "current_value": 30 + } + ] + } + """# + + let response = try JSONDecoder().decode( + CodexProvider.SelfServiceUsageResponse.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(response.limits.count, 2) + XCTAssertEqual(response.limits.map(\.limitWindow), ["5h", "168h"]) + XCTAssertEqual(response.limits.map(\.currentValue), [10, 30]) + } + + func testConfigAPIKeySourceLabelDoesNotUseUnknownFallback() { + XCTAssertEqual( + provider.sourceSummary(["OpenCode Config (API Key)"], fallback: "Unknown"), + "OpenCode Config (API Key)" + ) + } func testCodexFixtureDecoding() throws { let fixture = try loadFixture(named: "codex_response") diff --git a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift index 28527cc9..063104d0 100644 --- a/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift @@ -43,6 +43,15 @@ final class GeminiCLIProviderTests: XCTestCase { let remainingPercentage = (minFraction ?? 0.0) * 100.0 XCTAssertEqual(remainingPercentage, 80.0) } + + func testGeminiCLIOAuthFallbackOnlyRunsForDifferentClient() { + XCTAssertFalse( + GeminiCLIProvider.shouldRetryWithGeminiCLIClient(primaryClientID: TokenManager.geminiClientId) + ) + XCTAssertTrue( + GeminiCLIProvider.shouldRetryWithGeminiCLIClient(primaryClientID: "plugin-client-id") + ) + } func testResetTimeParsingFromISO8601() throws { let fixture = try loadFixture(named: "gemini_response") diff --git a/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift index d6e6ea1e..32032922 100644 --- a/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/OpenCodeZenProviderTests.swift @@ -133,15 +133,21 @@ final class OpenCodeZenProviderTests: XCTestCase { } func testParseETimeSecondsParsesMinutesHoursAndDaysFormats() { - XCTAssertEqual(OpenCodeZenProvider.parseETimeSeconds("05:23"), 323) - XCTAssertEqual(OpenCodeZenProvider.parseETimeSeconds("01:02:03"), 3_723) - XCTAssertEqual(OpenCodeZenProvider.parseETimeSeconds("2-03:04:05"), 183_845) + let cases: [(String, Int?)] = [ + ("03:14", 194), + ("01:02:03", 3_723), + ("2-01:05:30", 176_730) + ] + + for (value, expected) in cases { + XCTAssertEqual(OpenCodeZenProvider.parseETimeSeconds(value), expected, "value: \(value)") + } } func testParseETimeSecondsReturnsNilForMalformedInput() { - XCTAssertNil(OpenCodeZenProvider.parseETimeSeconds("03")) - XCTAssertNil(OpenCodeZenProvider.parseETimeSeconds("garbage")) - XCTAssertNil(OpenCodeZenProvider.parseETimeSeconds("")) + for value in ["03", "garbage", ""] { + XCTAssertNil(OpenCodeZenProvider.parseETimeSeconds(value), "value: \(value)") + } } func testStaleOpenCodeStatsPIDsIncludesOnlyStaleOpenCodeStatsLines() { diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index bebf3d2b..9f5add6a 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -12,4 +12,45 @@ final class ZaiCodingPlanProviderTests: XCTestCase { let provider = ZaiCodingPlanProvider() XCTAssertEqual(provider.type, .quotaBased) } + + func testTransientNetworkErrorClassification() { + let wrappedTimeout = NSError( + domain: "ZaiCodingPlanProviderTests", + code: 1, + userInfo: [ + NSUnderlyingErrorKey: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + ] + ) + let cases: [(Error, Bool)] = [ + (ProviderError.networkError("HTTP 500"), true), + (ProviderError.networkError("TLS handshake failed"), true), + (ProviderError.networkError("HTTP 400"), false), + (ProviderError.authenticationFailed("Invalid API key"), false), + (NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut), true), + (NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost), true), + (NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet), true), + (NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost), true), + (wrappedTimeout, true), + (NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL), false) + ] + + for (error, expected) in cases { + XCTAssertEqual( + ZaiCodingPlanProvider.isTransientNetworkError(error), + expected, + "error: \(error.localizedDescription)" + ) + } + } + + func testRetryDelayUsesBoundedJitter() { + XCTAssertEqual( + ZaiCodingPlanProvider.retryDelayNanoseconds(for: 1, jitter: 0), + 500_000_000 + ) + XCTAssertEqual( + ZaiCodingPlanProvider.retryDelayNanoseconds(for: 2, jitter: 250_000_000), + 1_250_000_000 + ) + } }