diff --git a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift index dabdb53..98cefa4 100644 --- a/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift +++ b/CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift @@ -33,6 +33,20 @@ actor CopilotCLIProvider: ProviderProtocol { let usage: ProviderUsage let details: DetailedUsage let sourcePriority: Int + let isPlaceholder: Bool + + var dedupeInput: CopilotCandidateDedupeInput { + CopilotCandidateDedupeInput( + accountId: accountId, + email: details.email, + planType: details.planType, + totalEntitlement: usage.totalEntitlement, + remainingQuota: usage.remainingQuota, + usedRequests: details.copilotUsedRequests, + limitRequests: details.copilotLimitRequests, + isPlaceholder: isPlaceholder + ) + } } // MARK: - ProviderProtocol Implementation @@ -256,7 +270,8 @@ actor CopilotCLIProvider: ProviderProtocol { accountId: login, usage: providerUsage, details: details, - sourcePriority: sourcePriority + sourcePriority: sourcePriority, + isPlaceholder: false ) } @@ -287,7 +302,8 @@ actor CopilotCLIProvider: ProviderProtocol { accountId: info.accountId ?? info.login, usage: providerUsage, details: details, - sourcePriority: sourcePriority(info.source) + sourcePriority: sourcePriority(info.source), + isPlaceholder: false ) } @@ -297,10 +313,11 @@ actor CopilotCLIProvider: ProviderProtocol { candidates: [CopilotAccountCandidate], cookieCandidate: CopilotAccountCandidate? ) -> ProviderResult { + let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) let merged = CandidateDedupe.merge( candidates, - accountId: { $0.accountId }, - isSameUsage: { _, _ in false }, + accountId: { CopilotCandidateDedupe.normalizedIdentity($0.accountId) }, + isSameUsage: { isDuplicateCopilotUsage($0, $1) }, priority: { $0.sourcePriority } ) let sorted = merged.sorted { $0.sourcePriority > $1.sourcePriority } @@ -338,7 +355,7 @@ actor CopilotCLIProvider: ProviderProtocol { ) } - let primaryDetails = cookieCandidate?.details ?? accountResults.first?.details + let primaryDetails = accountResults.first?.details ?? cookieCandidate?.details logger.info("CopilotCLIProvider: Finalized \(accountResults.count) account row(s)") @@ -349,6 +366,29 @@ actor CopilotCLIProvider: ProviderProtocol { ) } + private func removePlaceholderCandidatesWhenRealUsageExists( + _ candidates: [CopilotAccountCandidate] + ) -> [CopilotAccountCandidate] { + let hasRealUsage = candidates.contains { candidate in + (candidate.usage.totalEntitlement ?? 0) > 0 + } + guard hasRealUsage else { return candidates } + + return candidates.filter { candidate in + !CopilotCandidateDedupe.shouldDropPlaceholder( + candidate.dedupeInput, + whenAnyCandidateHasRealUsage: hasRealUsage + ) + } + } + + private func isDuplicateCopilotUsage( + _ lhs: CopilotAccountCandidate, + _ rhs: CopilotAccountCandidate + ) -> Bool { + CopilotCandidateDedupe.isSameAccountUsage(lhs.dedupeInput, rhs.dedupeInput) + } + // MARK: - Cookie-based Fetching (fallback) private func fetchCustomerId(cookies: GitHubCookies) async -> String? { diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift index f336cf5..e3fac9b 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift @@ -1155,6 +1155,83 @@ struct CandidateDedupe { } } +struct CopilotCandidateDedupeInput { + let accountId: String? + let email: String? + let planType: String? + let totalEntitlement: Int? + let remainingQuota: Int? + let usedRequests: Int? + let limitRequests: Int? + let isPlaceholder: Bool +} + +enum CopilotCandidateDedupe { + static func normalizedIdentity(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized.isEmpty ? nil : normalized + } + + static func shouldDropPlaceholder( + _ candidate: CopilotCandidateDedupeInput, + whenAnyCandidateHasRealUsage hasRealUsage: Bool + ) -> Bool { + guard hasRealUsage else { return false } + return candidate.isPlaceholder && (candidate.totalEntitlement ?? 0) == 0 + } + + static func isSameAccountUsage( + _ lhs: CopilotCandidateDedupeInput, + _ rhs: CopilotCandidateDedupeInput + ) -> Bool { + guard lhs.totalEntitlement == rhs.totalEntitlement, + lhs.remainingQuota == rhs.remainingQuota, + (lhs.totalEntitlement ?? 0) > 0 else { + return false + } + + if let lhsUsed = lhs.usedRequests, + let rhsUsed = rhs.usedRequests, + lhsUsed != rhsUsed { + return false + } + + if let lhsLimit = lhs.limitRequests, + let rhsLimit = rhs.limitRequests, + lhsLimit != rhsLimit { + return false + } + + let lhsPlan = normalizedIdentity(lhs.planType) + let rhsPlan = normalizedIdentity(rhs.planType) + if let lhsPlan, let rhsPlan, lhsPlan != rhsPlan { + return false + } + + let lhsIdentity = identityCandidates(lhs) + let rhsIdentity = identityCandidates(rhs) + guard !lhsIdentity.isEmpty, !rhsIdentity.isEmpty else { + return false + } + + return !lhsIdentity.isDisjoint(with: rhsIdentity) + } + + private static func identityCandidates(_ candidate: CopilotCandidateDedupeInput) -> Set { + var identities = Set() + if let accountId = normalizedIdentity(candidate.accountId) { + identities.insert(accountId) + } + if let email = normalizedIdentity(candidate.email) { + identities.insert(email) + } + return identities + } +} + /// Shared numeric parser for API response dictionaries. /// APIs may return Double, Int, NSNumber, or String for numeric fields. enum APIValueParser { diff --git a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift index 3a4db53..ffa8e26 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift @@ -168,6 +168,20 @@ final class CopilotProvider: ProviderProtocol { let usage: ProviderUsage let details: DetailedUsage let sourcePriority: Int + let isPlaceholder: Bool + + var dedupeInput: CopilotCandidateDedupeInput { + CopilotCandidateDedupeInput( + accountId: accountId, + email: details.email, + planType: details.planType, + totalEntitlement: usage.totalEntitlement, + remainingQuota: usage.remainingQuota, + usedRequests: details.copilotUsedRequests, + limitRequests: details.copilotLimitRequests, + isPlaceholder: isPlaceholder + ) + } } private func sourcePriority(_ source: CopilotAuthSource?) -> Int { @@ -289,7 +303,8 @@ final class CopilotProvider: ProviderProtocol { accountId: login, usage: providerUsage, details: details, - sourcePriority: sourcePriority + sourcePriority: sourcePriority, + isPlaceholder: false ) } @@ -320,7 +335,8 @@ final class CopilotProvider: ProviderProtocol { accountId: info.accountId ?? info.login, usage: providerUsage, details: details, - sourcePriority: sourcePriority(info.source) + sourcePriority: sourcePriority(info.source), + isPlaceholder: false ) } @@ -331,7 +347,8 @@ final class CopilotProvider: ProviderProtocol { accountId: details.email, usage: cachedResult.usage, details: details, - sourcePriority: 0 + sourcePriority: 0, + isPlaceholder: true ) } @@ -339,10 +356,11 @@ final class CopilotProvider: ProviderProtocol { candidates: [CopilotAccountCandidate], cookieCandidate: CopilotAccountCandidate? ) -> ProviderResult { + let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) let merged = CandidateDedupe.merge( candidates, - accountId: { $0.accountId }, - isSameUsage: { _, _ in false }, + accountId: { CopilotCandidateDedupe.normalizedIdentity($0.accountId) }, + isSameUsage: { isDuplicateCopilotUsage($0, $1) }, priority: { $0.sourcePriority } ) let sorted = merged.sorted { $0.sourcePriority > $1.sourcePriority } @@ -380,7 +398,7 @@ final class CopilotProvider: ProviderProtocol { ) } - let primaryDetails = cookieCandidate?.details ?? accountResults.first?.details + let primaryDetails = accountResults.first?.details ?? cookieCandidate?.details logger.info("CopilotProvider: Finalized \(accountResults.count) account row(s)") @@ -391,6 +409,29 @@ final class CopilotProvider: ProviderProtocol { ) } + private func removePlaceholderCandidatesWhenRealUsageExists( + _ candidates: [CopilotAccountCandidate] + ) -> [CopilotAccountCandidate] { + let hasRealUsage = candidates.contains { candidate in + (candidate.usage.totalEntitlement ?? 0) > 0 + } + guard hasRealUsage else { return candidates } + + return candidates.filter { candidate in + !CopilotCandidateDedupe.shouldDropPlaceholder( + candidate.dedupeInput, + whenAnyCandidateHasRealUsage: hasRealUsage + ) + } + } + + private func isDuplicateCopilotUsage( + _ lhs: CopilotAccountCandidate, + _ rhs: CopilotAccountCandidate + ) -> Bool { + CopilotCandidateDedupe.isSameAccountUsage(lhs.dedupeInput, rhs.dedupeInput) + } + // MARK: - Customer ID Fetching private func fetchCustomerId(cookies: GitHubCookies) async -> String? { diff --git a/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift index ed46b65..b777109 100644 --- a/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CopilotProviderTests.swift @@ -142,6 +142,87 @@ final class CopilotProviderTests: XCTestCase { XCTAssertEqual(CopilotAuthSource.vscodeApps.priority, 0) } + // MARK: - CopilotCandidateDedupe + + func testCopilotDedupeRejectsMatchingUsageWithDifferentIdentities() { + let personal = makeDedupeInput(accountId: "personal", email: "personal@example.com") + let work = makeDedupeInput(accountId: "work", email: "work@example.com") + + XCTAssertFalse(CopilotCandidateDedupe.isSameAccountUsage(personal, work)) + } + + func testCopilotDedupeAcceptsMatchingUsageWithCaseInsensitiveIdentity() { + let first = makeDedupeInput(accountId: "Foo", email: "Foo@Example.com") + let second = makeDedupeInput(accountId: "foo", email: "foo@example.com") + + XCTAssertTrue(CopilotCandidateDedupe.isSameAccountUsage(first, second)) + } + + func testCopilotDedupeRejectsIdentitylessMatchingUsage() { + let identified = makeDedupeInput(accountId: "foo", email: "foo@example.com") + let identityless = makeDedupeInput(accountId: nil, email: nil) + + XCTAssertFalse(CopilotCandidateDedupe.isSameAccountUsage(identified, identityless)) + } + + func testCopilotDedupeDropsOnlyPlaceholderWithoutRealUsage() { + let placeholder = makeDedupeInput( + accountId: nil, + email: nil, + entitlement: 0, + remaining: 0, + isPlaceholder: true + ) + let emptyRealCandidate = makeDedupeInput( + accountId: nil, + email: nil, + entitlement: 0, + remaining: 0, + isPlaceholder: false + ) + + XCTAssertTrue( + CopilotCandidateDedupe.shouldDropPlaceholder( + placeholder, + whenAnyCandidateHasRealUsage: true + ) + ) + XCTAssertFalse( + CopilotCandidateDedupe.shouldDropPlaceholder( + emptyRealCandidate, + whenAnyCandidateHasRealUsage: true + ) + ) + XCTAssertFalse( + CopilotCandidateDedupe.shouldDropPlaceholder( + placeholder, + whenAnyCandidateHasRealUsage: false + ) + ) + } + + private func makeDedupeInput( + accountId: String?, + email: String?, + entitlement: Int = 300, + remaining: Int = 284, + usedRequests: Int? = 16, + limitRequests: Int? = 300, + planType: String? = "Individual", + isPlaceholder: Bool = false + ) -> CopilotCandidateDedupeInput { + CopilotCandidateDedupeInput( + accountId: accountId, + email: email, + planType: planType, + totalEntitlement: entitlement, + remainingQuota: remaining, + usedRequests: usedRequests, + limitRequests: limitRequests, + isPlaceholder: isPlaceholder + ) + } + private func loadFixture(named: String) -> Data { let bundle = Bundle(for: type(of: self)) guard let url = bundle.url(forResource: named, withExtension: nil) else {