diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index bcfee9fe58..4ea1cfe756 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -19,6 +19,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { public struct QuotaSnapshot: Sendable, Decodable { public let entitlement: Double public let remaining: Double + public let creditsUsed: Double? public let percentRemaining: Double public let quotaId: String public let hasPercentRemaining: Bool @@ -55,9 +56,17 @@ public struct CopilotUsageResponse: Sendable, Decodable { .remaining == 0 } + /// Whether the snapshot carries a real absolute credit counter, even when + /// it lacks a usable percentage window. Such snapshots stay accessible in + /// the decoded response without ever becoming a fake percentage bar. + public var carriesCreditsCounter: Bool { + self.creditsUsed != nil + } + private enum CodingKeys: String, CodingKey { case entitlement case remaining + case creditsUsed = "credits_used" case percentRemaining = "percent_remaining" case quotaId = "quota_id" case unlimited @@ -68,11 +77,13 @@ public struct CopilotUsageResponse: Sendable, Decodable { remaining: Double, percentRemaining: Double, quotaId: String, + creditsUsed: Double? = nil, hasPercentRemaining: Bool = true, unlimited: Bool = false) { self.entitlement = entitlement self.remaining = remaining + self.creditsUsed = creditsUsed self.percentRemaining = unlimited ? 100 : percentRemaining self.quotaId = quotaId self.hasPercentRemaining = unlimited || hasPercentRemaining @@ -89,6 +100,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.remaining = decodedRemaining ?? 0 self.entitlementWasDecoded = decodedEntitlement != nil self.remainingWasDecoded = decodedRemaining != nil + self.creditsUsed = Self.decodeNumberIfPresent(container: container, key: .creditsUsed) let decodedUnlimited = try container.decodeIfPresent(Bool.self, forKey: .unlimited) ?? false let decodedPercent = Self.decodeNumberIfPresent(container: container, key: .percentRemaining) if decodedUnlimited { @@ -113,6 +125,43 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.unlimited = decodedUnlimited } + private init( + entitlement: Double, + remaining: Double, + creditsUsed: Double?, + percentRemaining: Double, + quotaId: String, + hasPercentRemaining: Bool, + unlimited: Bool, + entitlementWasDecoded: Bool, + remainingWasDecoded: Bool) + { + self.entitlement = entitlement + self.remaining = remaining + self.creditsUsed = creditsUsed + self.percentRemaining = percentRemaining + self.quotaId = quotaId + self.hasPercentRemaining = hasPercentRemaining + self.unlimited = unlimited + self.entitlementWasDecoded = entitlementWasDecoded + self.remainingWasDecoded = remainingWasDecoded + } + + /// Returns a copy carrying `creditsUsed`, preserving the decoded-flag + /// semantics that placeholder classification depends on. + fileprivate func withCreditsUsed(_ creditsUsed: Double?) -> QuotaSnapshot { + QuotaSnapshot( + entitlement: self.entitlement, + remaining: self.remaining, + creditsUsed: creditsUsed, + percentRemaining: self.percentRemaining, + quotaId: self.quotaId, + hasPercentRemaining: self.hasPercentRemaining, + unlimited: self.unlimited, + entitlementWasDecoded: self.entitlementWasDecoded, + remainingWasDecoded: self.remainingWasDecoded) + } + private static func decodeNumberIfPresent( container: KeyedDecodingContainer, key: CodingKeys) -> Double? @@ -185,10 +234,10 @@ public struct CopilotUsageResponse: Sendable, Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) var premium = try container.decodeIfPresent(QuotaSnapshot.self, forKey: .premiumInteractions) var chat = try container.decodeIfPresent(QuotaSnapshot.self, forKey: .chat) - if premium?.isPlaceholder == true { + if premium?.isPlaceholder == true, premium?.carriesCreditsCounter != true { premium = nil } - if chat?.isPlaceholder == true { + if chat?.isPlaceholder == true, chat?.carriesCreditsCounter != true { chat = nil } @@ -204,7 +253,7 @@ public struct CopilotUsageResponse: Sendable, Decodable { guard let decoded = try dynamic.decodeIfPresent(QuotaSnapshot.self, forKey: key) else { continue } - guard !decoded.isPlaceholder else { continue } + guard !decoded.isPlaceholder || decoded.carriesCreditsCounter else { continue } value = decoded } catch { continue @@ -348,8 +397,35 @@ public struct CopilotUsageResponse: Sendable, Decodable { fallback: QuotaSnapshot?) -> QuotaSnapshot? { if direct?.unlimited == true, let fallback = usableQuotaSnapshot(from: fallback) { - return fallback + // The direct snapshot's absolute credit counter is real consumption + // even though its unlimited marker makes it ineligible for a + // percentage window; keep the counter on the selected fallback. + return fallback.withCreditsUsed(direct?.creditsUsed) + } + if let directWindow = self.usableQuotaSnapshot(from: direct) { + return directWindow + } + guard let fallback = self.usableQuotaSnapshot(from: fallback) else { + return nil } - return self.usableQuotaSnapshot(from: direct) ?? self.usableQuotaSnapshot(from: fallback) + // A zero-entitlement placeholder can still carry a real absolute + // counter; keep it on the selected fallback instead of dropping it. + if direct?.carriesCreditsCounter == true { + return fallback.withCreditsUsed(direct?.creditsUsed) + } + return fallback + } +} + +/// Token-billed Copilot seats report consumption as an absolute credit counter +/// rather than a percentage window. Carried separately from rate windows so the +/// value stays accessible without inventing a fake quota denominator. +public struct CopilotCreditsSnapshot: Sendable, Codable, Equatable { + public let creditsUsed: Double + public let quotaResetDate: Date? + + public init(creditsUsed: Double, quotaResetDate: Date? = nil) { + self.creditsUsed = creditsUsed + self.quotaResetDate = quotaResetDate } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index 756f2c59eb..268b2733e2 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -71,6 +71,10 @@ public struct CopilotUsageFetcher: Sendable { let chatSnapshot = usage.quotaSnapshots.chat let premium = Self.makeRateWindow(from: premiumSnapshot, resetsAt: resetsAt) let chat = Self.makeRateWindow(from: chatSnapshot, resetsAt: resetsAt) + let creditsUsed = premiumSnapshot?.creditsUsed ?? chatSnapshot?.creditsUsed + let copilotCredits = creditsUsed.map { + CopilotCreditsSnapshot(creditsUsed: $0, quotaResetDate: resetsAt) + } let hasUnlimitedQuota = premiumSnapshot?.unlimited == true || chatSnapshot?.unlimited == true let primary: RateWindow? @@ -102,6 +106,7 @@ public struct CopilotUsageFetcher: Sendable { secondary: secondary, tertiary: nil, providerCost: nil, + copilotCredits: copilotCredits, updatedAt: Date(), identity: identity) } diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index 5b383aa8ea..73be5b379d 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -145,6 +145,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { public let extraWindowCount: Int public let providerCostPresent: Bool public let providerSpecificData: [String] + public let copilotCredits: ProviderDiagnosticCopilotCredits? private enum CodingKeys: String, CodingKey { case updatedAt @@ -153,6 +154,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { case extraWindowCount case providerCostPresent case providerSpecificData + case copilotCredits } public init(from snapshot: UsageSnapshot) { @@ -187,6 +189,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { if snapshot.deepgramUsage != nil { providerSpecificData.append("deepgramUsage") } if snapshot.xaiUsage != nil { providerSpecificData.append("xaiUsage") } if snapshot.cursorRequests != nil { providerSpecificData.append("cursorRequests") } + if snapshot.copilotCredits != nil { providerSpecificData.append("copilotCredits") } self.updatedAt = snapshot.updatedAt self.dataConfidence = snapshot.dataConfidence.rawValue @@ -194,6 +197,11 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { self.extraWindowCount = snapshot.extraRateWindows?.count ?? 0 self.providerCostPresent = snapshot.providerCost != nil self.providerSpecificData = providerSpecificData.sorted() + self.copilotCredits = snapshot.copilotCredits.map { + ProviderDiagnosticCopilotCredits( + creditsUsed: $0.creditsUsed, + quotaResetDate: $0.quotaResetDate) + } } public init(from decoder: Decoder) throws { @@ -205,6 +213,19 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { self.extraWindowCount = try container.decode(Int.self, forKey: .extraWindowCount) self.providerCostPresent = try container.decode(Bool.self, forKey: .providerCostPresent) self.providerSpecificData = try container.decode([String].self, forKey: .providerSpecificData) + self.copilotCredits = try container.decodeIfPresent( + ProviderDiagnosticCopilotCredits.self, + forKey: .copilotCredits) + } +} + +public struct ProviderDiagnosticCopilotCredits: Codable, Sendable { + public let creditsUsed: Double + public let quotaResetDate: Date? + + public init(creditsUsed: Double, quotaResetDate: Date?) { + self.creditsUsed = creditsUsed + self.quotaResetDate = quotaResetDate } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 8a5729fe11..45bf3d2a1d 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -171,6 +171,7 @@ public struct UsageSnapshot: Codable, Sendable { public let poeUsage: PoeUsageHistorySnapshot? public let xaiUsage: XAIUsageSnapshot? public let cursorRequests: CursorRequestUsage? + public let copilotCredits: CopilotCreditsSnapshot? /// Live-only marker for optional Command Code subscription lookup failure. public let commandCodeSubscriptionEnrichmentUnavailable: Bool /// Live-only marker that Command Code returned a recognized subscription plan. @@ -247,6 +248,7 @@ public struct UsageSnapshot: Codable, Sendable { poeUsage: PoeUsageHistorySnapshot? = nil, xaiUsage: XAIUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, + copilotCredits: CopilotCreditsSnapshot? = nil, commandCodeSubscriptionEnrichmentUnavailable: Bool = false, commandCodeHasSubscriptionPlan: Bool = false, commandCodeMonthlyGrantDepleted: Bool = false, @@ -289,6 +291,7 @@ public struct UsageSnapshot: Codable, Sendable { self.poeUsage = poeUsage self.xaiUsage = xaiUsage self.cursorRequests = cursorRequests + self.copilotCredits = copilotCredits self.commandCodeSubscriptionEnrichmentUnavailable = commandCodeSubscriptionEnrichmentUnavailable self.commandCodeHasSubscriptionPlan = commandCodeHasSubscriptionPlan self.commandCodeMonthlyGrantDepleted = commandCodeMonthlyGrantDepleted @@ -364,6 +367,7 @@ public struct UsageSnapshot: Codable, Sendable { self.poeUsage = try container.decodeIfPresent(PoeUsageHistorySnapshot.self, forKey: .poeUsage) self.xaiUsage = try container.decodeIfPresent(XAIUsageSnapshot.self, forKey: .xaiUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time + self.copilotCredits = nil // Not persisted, fetched fresh each time self.commandCodeSubscriptionEnrichmentUnavailable = false // Live-only fetch state self.commandCodeHasSubscriptionPlan = false // Live-only fetch state self.commandCodeMonthlyGrantDepleted = false // Live-only fetch state @@ -603,6 +607,7 @@ public struct UsageSnapshot: Codable, Sendable { poeUsage: self.poeUsage, xaiUsage: self.xaiUsage, cursorRequests: self.cursorRequests, + copilotCredits: self.copilotCredits, commandCodeSubscriptionEnrichmentUnavailable: self.commandCodeSubscriptionEnrichmentUnavailable, commandCodeHasSubscriptionPlan: self.commandCodeHasSubscriptionPlan, commandCodeMonthlyGrantDepleted: self.commandCodeMonthlyGrantDepleted, diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift index d590632e69..9610f50369 100644 --- a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -42,18 +42,21 @@ struct CopilotUsageFetcherTests { { "copilot_plan": "business", "token_based_billing": true, + "quota_reset_date": "2026-09-01", "quota_snapshots": { "premium_interactions": { "entitlement": 0, "remaining": 0, "percent_remaining": 100, - "quota_id": "premium_interactions" + "quota_id": "premium_interactions", + "credits_used": 31 }, "chat": { "entitlement": 0, "remaining": 0, "percent_remaining": 100, - "quota_id": "chat" + "quota_id": "chat", + "credits_used": 0 } } } @@ -66,9 +69,96 @@ struct CopilotUsageFetcherTests { #expect(snapshot.primary == nil) #expect(snapshot.secondary == nil) + #expect(snapshot.copilotCredits?.creditsUsed == 31) + #expect(snapshot.copilotCredits?.quotaResetDate != nil) #expect(snapshot.identity?.loginMethod == "Business") } + @Test + func `fetch retains token billed credits counter across zero entitlement fallback`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_reset_date": "2026-09-01", + "monthly_quotas": { "completions": 300 }, + "limited_user_quotas": { "completions": 75 }, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions", + "credits_used": 31 + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary?.usedPercent == 75) + #expect(snapshot.copilotCredits?.creditsUsed == 31) + } + + @Test + func `fetch retains token billed credits counter across monthly quota fallback`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder") + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let data = Data( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_reset_date": "2026-09-01", + "monthly_quotas": { "completions": 300 }, + "limited_user_quotas": { "completions": 75 }, + "quota_snapshots": { + "premium_interactions": { + "unlimited": true, + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions", + "credits_used": 31 + }, + "chat": { + "unlimited": true, + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "chat", + "credits_used": 0 + } + } + } + """.utf8) + return (data, response) + } + let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport) + + let snapshot = try await fetcher.fetch() + + #expect(snapshot.primary?.usedPercent == 75) + #expect(snapshot.copilotCredits?.creditsUsed == 31) + } + @Test func `fetch omits explicitly unlimited only chat quota without failing`() async throws { let transport = ProviderHTTPTransportStub { request in diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index 4d42656c77..35c13e5c33 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -529,6 +529,123 @@ struct CopilotUsageModelsTests { #expect(!chat.isPlaceholder) } + @Test + func `decodes credits used counter from token billed business payload`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_reset_date": "2026-09-01", + "quota_snapshots": { + "premium_interactions": { + "unlimited": true, + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100.0, + "quota_id": "premium_interactions", + "credits_used": 31 + }, + "chat": { + "unlimited": true, + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100.0, + "quota_id": "chat", + "credits_used": 0 + } + } + } + """) + + let premium = try #require(response.quotaSnapshots.premiumInteractions) + #expect(response.tokenBasedBilling == true) + #expect(premium.creditsUsed == 31) + #expect(premium.unlimited) + #expect(premium.usedPercent == 0) + } + + @Test + func `keeps credit counter when zero entitlement direct snapshot falls back to monthly quota`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "monthly_quotas": { "completions": 300 }, + "limited_user_quotas": { "completions": 75 }, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions", + "credits_used": 31 + } + } + } + """) + + let premium = try #require(response.quotaSnapshots.premiumInteractions) + #expect(premium.creditsUsed == 31) + #expect(premium.quotaId == "completions") + #expect(premium.usedPercent == 75) + } + + @Test + func `keeps credit counter when unlimited direct snapshot falls back to monthly quota`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "monthly_quotas": { "completions": 300 }, + "limited_user_quotas": { "completions": 75 }, + "quota_snapshots": { + "premium_interactions": { + "unlimited": true, + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions", + "credits_used": 31 + } + } + } + """) + + let premium = try #require(response.quotaSnapshots.premiumInteractions) + #expect(premium.creditsUsed == 31) + #expect(premium.quotaId == "completions") + #expect(premium.usedPercent == 75) + } + + @Test + func `keeps credit counter accessible without unlimited marker`() throws { + // A zero-entitlement snapshot without `unlimited` is still a placeholder + // for percentage windows, but its absolute counter must not be lost. + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions", + "credits_used": 31 + } + } + } + """) + + let premium = try #require(response.quotaSnapshots.premiumInteractions) + #expect(premium.creditsUsed == 31) + #expect(premium.isPlaceholder) + } + @Test func `flags zero entitlement snapshot as placeholder`() { let snapshot = CopilotUsageResponse.QuotaSnapshot( diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift index 5209e3596f..dc176bc016 100644 --- a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -48,6 +48,27 @@ struct ProviderDiagnosticExportTests { #expect(!json.contains("localizedDescription")) } + @Test + func `diagnostic export carries copilot credits counter`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + copilotCredits: CopilotCreditsSnapshot( + creditsUsed: 31, + quotaResetDate: now.addingTimeInterval(86400)), + updatedAt: now) + let summary = ProviderDiagnosticUsageSummary(from: snapshot) + + #expect(summary.copilotCredits?.creditsUsed == 31) + #expect(summary.copilotCredits?.quotaResetDate != nil) + #expect(summary.providerSpecificData.contains("copilotCredits")) + + let json = try self.json(summary) + #expect(json.contains("\"copilotCredits\"")) + #expect(json.contains("31")) + } + @Test func `diagnostic export decodes legacy schema without platform metadata`() throws { let export = ProviderDiagnosticExport(