From edcbd1e4b13c195e257b0fe64caf7024ab7d394c Mon Sep 17 00:00:00 2001 From: Ruben <17501732+Daltonganger@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:56:38 +0200 Subject: [PATCH 1/3] Fix provider fetch stability issues --- .../Providers/AntigravityProvider.swift | 12 +++-- .../Providers/CodexProvider.swift | 5 +- .../Providers/GeminiCLIProvider.swift | 22 +++++--- .../Providers/OpenCodeZenProvider.swift | 6 +++ .../Providers/ZaiCodingPlanProvider.swift | 54 +++++++++++++++++++ .../Services/TokenManager.swift | 27 ++++++++++ 6 files changed, 114 insertions(+), 12 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift index c73f9d19..85312c21 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift @@ -276,10 +276,14 @@ final class AntigravityProvider: ProviderProtocol { 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: String + if !primaryProjectId.isEmpty { + projectId = primaryProjectId + } else if !fallbackProjectId.isEmpty { + projectId = fallbackProjectId + } else { + projectId = "default" + logger.warning("Antigravity fallback account is missing project ID; using default project fallback") } return AntigravityFallbackAccount( diff --git a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift index 8f002e43..7c9e4e8e 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift @@ -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,7 +208,9 @@ 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)) ?? [] + limits = localLimits + upstreamLimits } } diff --git a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift index 9a52f00f..ba70f226 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift @@ -249,19 +249,27 @@ 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 { - throw ProviderError.authenticationFailed("Missing project ID for account #\(accountIndex + 1)") - } + let configuredProjectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) + let projectId = configuredProjectId.isEmpty ? "default" : configuredProjectId - guard let accessToken = await tokenManager.refreshGeminiAccessToken( + var refreshedAccessToken = await tokenManager.refreshGeminiAccessToken( refreshToken: account.refreshToken, clientId: account.clientId, clientSecret: account.clientSecret - ) else { + ) + if refreshedAccessToken == nil { + 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)") } + if configuredProjectId.isEmpty { + logger.info("Gemini CLI: Missing project ID for account #\(accountIndex + 1); using default project fallback") + } + let resolvedEmail = await resolveGeminiAccountEmail(primaryEmail: account.email, accessToken: accessToken) if resolvedEmail == "Unknown" { logger.warning("Gemini CLI: Email lookup failed for account #\(accountIndex + 1)") @@ -275,7 +283,7 @@ 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 + // project parameter improves model coverage; use a default fallback when auth storage omits it. request.httpBody = "{\"project\":\"\(projectId)\"}".data(using: .utf8) let (data, response) = try await session.data(for: request) 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..7ede2fd8 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -1,4 +1,5 @@ import Foundation +import Foundation import os.log private let logger = Logger(subsystem: "com.opencodeproviders", category: "ZaiCodingPlanProvider") @@ -155,6 +156,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 +290,28 @@ 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, isTransientNetworkError(error) else { + throw error + } + + logger.warning("Z.AI Coding Plan request failed with transient error on attempt \(attempt)/\(maxAttempts): \(error.localizedDescription)") + try await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000) + } + } + + 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 +334,36 @@ final class ZaiCodingPlanProvider: ProviderProtocol { return data } + private func isTransientNetworkError(_ error: Error) -> Bool { + if let providerError = error as? ProviderError { + switch providerError { + case .networkError(let message): + return message.contains("HTTP 5") || message.localizedCaseInsensitiveContains("tls") + default: + return false + } + } + + let nsError = error as NSError + guard nsError.domain == NSURLErrorDomain else { return false } + + switch nsError.code { + case NSURLErrorNetworkConnectionLost, + NSURLErrorTimedOut, + 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..d6566b53 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 { From 1b6c271cb50d80688e9784d31586ddbc0cbc9df9 Mon Sep 17 00:00:00 2001 From: Ruben <17501732+Daltonganger@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:24:46 +0200 Subject: [PATCH 2/3] fix: address provider fetch review feedback --- .../CopilotMonitor.xcodeproj/project.pbxproj | 4 ++ .../Providers/AntigravityProvider.swift | 12 ++--- .../Providers/CodexProvider.swift | 33 ++++++++++-- .../Providers/GeminiCLIProvider.swift | 20 +++++--- .../Providers/ZaiCodingPlanProvider.swift | 5 +- .../Services/TokenManager.swift | 2 +- .../CodexProviderTests.swift | 51 +++++++++++++++++++ .../GeminiCLIProviderTests.swift | 9 ++++ .../OpenCodeZenProviderTests.swift | 18 ++++--- .../ZaiCodingPlanProviderTests.swift | 19 +++++++ 10 files changed, 143 insertions(+), 30 deletions(-) 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/AntigravityProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift index 85312c21..c73f9d19 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift @@ -276,14 +276,10 @@ final class AntigravityProvider: ProviderProtocol { let primaryProjectId = account.projectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let fallbackProjectId = account.managedProjectId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let projectId: String - if !primaryProjectId.isEmpty { - projectId = primaryProjectId - } else if !fallbackProjectId.isEmpty { - projectId = fallbackProjectId - } else { - projectId = "default" - logger.warning("Antigravity fallback account is missing project ID; using default project fallback") + let projectId = primaryProjectId.isEmpty ? fallbackProjectId : primaryProjectId + guard !projectId.isEmpty else { + logger.warning("Antigravity fallback unavailable: selected account is missing project ID") + return nil } return AntigravityFallbackAccount( diff --git a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift index 7c9e4e8e..5d3bdbb6 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? @@ -210,11 +210,36 @@ final class CodexProvider: ProviderProtocol { totalCostUSD = try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) let localLimits = (try? container.decodeIfPresent([SelfServiceLimit].self, forKey: .limits)) ?? [] let upstreamLimits = (try? container.decodeIfPresent([SelfServiceLimit].self, forKey: .upstreamLimits)) ?? [] - limits = localLimits + 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() + 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? @@ -403,7 +428,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 ba70f226..0b1bb030 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift @@ -250,14 +250,18 @@ final class GeminiCLIProvider: ProviderProtocol { private func fetchQuotaForAccount(account: GeminiAuthAccount) async throws -> GeminiAccountQuota { let accountIndex = account.index let configuredProjectId = account.projectId.trimmingCharacters(in: .whitespacesAndNewlines) - let projectId = configuredProjectId.isEmpty ? "default" : configuredProjectId + 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)") + } var refreshedAccessToken = await tokenManager.refreshGeminiAccessToken( refreshToken: account.refreshToken, clientId: account.clientId, clientSecret: account.clientSecret ) - if refreshedAccessToken == nil { + 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) } @@ -266,10 +270,6 @@ final class GeminiCLIProvider: ProviderProtocol { throw ProviderError.authenticationFailed("Unable to refresh token for account #\(accountIndex + 1)") } - if configuredProjectId.isEmpty { - logger.info("Gemini CLI: Missing project ID for account #\(accountIndex + 1); using default project fallback") - } - let resolvedEmail = await resolveGeminiAccountEmail(primaryEmail: account.email, accessToken: accessToken) if resolvedEmail == "Unknown" { logger.warning("Gemini CLI: Email lookup failed for account #\(accountIndex + 1)") @@ -283,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 improves model coverage; use a default fallback when auth storage omits it. - 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) @@ -351,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/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index 7ede2fd8..ddd0eb05 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -1,5 +1,4 @@ import Foundation -import Foundation import os.log private let logger = Logger(subsystem: "com.opencodeproviders", category: "ZaiCodingPlanProvider") @@ -299,7 +298,7 @@ final class ZaiCodingPlanProvider: ProviderProtocol { } catch { lastError = error - guard attempt < maxAttempts, isTransientNetworkError(error) else { + guard attempt < maxAttempts, Self.isTransientNetworkError(error) else { throw error } @@ -334,7 +333,7 @@ final class ZaiCodingPlanProvider: ProviderProtocol { return data } - private func isTransientNetworkError(_ error: Error) -> Bool { + static func isTransientNetworkError(_ error: Error) -> Bool { if let providerError = error as? ProviderError { switch providerError { case .networkError(let message): diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index d6566b53..ed6c014c 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -4473,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..b9dacd0d 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -12,4 +12,23 @@ final class ZaiCodingPlanProviderTests: XCTestCase { let provider = ZaiCodingPlanProvider() XCTAssertEqual(provider.type, .quotaBased) } + + func testTransientNetworkErrorClassification() { + 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: NSURLErrorBadURL), false) + ] + + for (error, expected) in cases { + XCTAssertEqual( + ZaiCodingPlanProvider.isTransientNetworkError(error), + expected, + "error: \(error.localizedDescription)" + ) + } + } } From 51a54124d70c032df73b323f6bd616bed7d5d0fa Mon Sep 17 00:00:00 2001 From: Ruben <17501732+Daltonganger@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:19:04 +0200 Subject: [PATCH 3/3] fix: harden provider retry handling --- .../Providers/CodexProvider.swift | 3 ++ .../Providers/ZaiCodingPlanProvider.swift | 37 +++++++++++++++++-- .../ZaiCodingPlanProviderTests.swift | 22 +++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift index 5d3bdbb6..d14b31d1 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift @@ -221,6 +221,9 @@ final class CodexProvider: ProviderProtocol { 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 } diff --git a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index ddd0eb05..0275236d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -303,7 +303,9 @@ final class ZaiCodingPlanProvider: ProviderProtocol { } logger.warning("Z.AI Coding Plan request failed with transient error on attempt \(attempt)/\(maxAttempts): \(error.localizedDescription)") - try await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000) + 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) } } @@ -333,7 +335,15 @@ 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): @@ -343,12 +353,31 @@ final class ZaiCodingPlanProvider: ProviderProtocol { } } - let nsError = error as NSError - guard nsError.domain == NSURLErrorDomain else { 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 + } - switch nsError.code { + private static func isTransientURLErrorCode(_ code: Int) -> Bool { + switch code { case NSURLErrorNetworkConnectionLost, NSURLErrorTimedOut, + NSURLErrorNotConnectedToInternet, NSURLErrorCannotConnectToHost, NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed, diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index b9dacd0d..9f5add6a 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -14,12 +14,23 @@ final class ZaiCodingPlanProviderTests: XCTestCase { } 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) ] @@ -31,4 +42,15 @@ final class ZaiCodingPlanProviderTests: XCTestCase { ) } } + + 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 + ) + } }