Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -236,6 +237,7 @@
OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeAuthDecodingTests.swift; sourceTree = "<group>"; };
OCZENTESTFR111111111111 /* OpenCodeZenProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeZenProviderTests.swift; sourceTree = "<group>"; };
CLAUDETESTFR11111111111 /* ClaudeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProviderTests.swift; sourceTree = "<group>"; };
ZAITESTFR11111111111111 /* ZaiCodingPlanProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZaiCodingPlanProviderTests.swift; sourceTree = "<group>"; };
TDDDDDDDDDDDDDDDDDDDDDD /* CopilotMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CopilotMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -463,6 +465,7 @@
OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */,
OCZENTESTFR111111111111 /* OpenCodeZenProviderTests.swift */,
CLAUDETESTFR11111111111 /* ClaudeProviderTests.swift */,
ZAITESTFR11111111111111 /* ZaiCodingPlanProviderTests.swift */,
);
path = CopilotMonitorTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
39 changes: 35 additions & 4 deletions CopilotMonitor/CopilotMonitor/Providers/CodexProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand All @@ -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<LimitKey>()
// 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
}
}
Comment thread
Daltonganger marked this conversation as resolved.

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?
Expand Down Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions CopilotMonitor/CopilotMonitor/Providers/GeminiCLIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
Daltonganger marked this conversation as resolved.
}

guard let accessToken = refreshedAccessToken else {
throw ProviderError.authenticationFailed("Unable to refresh token for account #\(accountIndex + 1)")
}

Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
}
}
Comment thread
Daltonganger marked this conversation as resolved.

private func decodeResponse<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let decoder = JSONDecoder()
if let envelope = try? decoder.decode(ZaiEnvelope<T>.self, from: data), let payload = envelope.data {
Expand Down
29 changes: 28 additions & 1 deletion CopilotMonitor/CopilotMonitor/Services/TokenManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions CopilotMonitor/CopilotMonitorTests/CodexProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading