Skip to content

Commit 2d76cd9

Browse files
authored
Decode Copilot credits_used for token-billed seats (#2593) (#2613)
* Decode Copilot token-billing credits used * Cover credits used counter in token-billing card regression * Keep Copilot credits counter accessible through snapshots * Preserve Copilot credits counter across quota fallback * Expose Copilot credits counter in diagnostics * Keep credits counter on zero entitlement quota fallback
1 parent ad4d168 commit 2d76cd9

7 files changed

Lines changed: 342 additions & 7 deletions

File tree

Sources/CodexBarCore/CopilotUsageModels.swift

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public struct CopilotUsageResponse: Sendable, Decodable {
1919
public struct QuotaSnapshot: Sendable, Decodable {
2020
public let entitlement: Double
2121
public let remaining: Double
22+
public let creditsUsed: Double?
2223
public let percentRemaining: Double
2324
public let quotaId: String
2425
public let hasPercentRemaining: Bool
@@ -55,9 +56,17 @@ public struct CopilotUsageResponse: Sendable, Decodable {
5556
.remaining == 0
5657
}
5758

59+
/// Whether the snapshot carries a real absolute credit counter, even when
60+
/// it lacks a usable percentage window. Such snapshots stay accessible in
61+
/// the decoded response without ever becoming a fake percentage bar.
62+
public var carriesCreditsCounter: Bool {
63+
self.creditsUsed != nil
64+
}
65+
5866
private enum CodingKeys: String, CodingKey {
5967
case entitlement
6068
case remaining
69+
case creditsUsed = "credits_used"
6170
case percentRemaining = "percent_remaining"
6271
case quotaId = "quota_id"
6372
case unlimited
@@ -68,11 +77,13 @@ public struct CopilotUsageResponse: Sendable, Decodable {
6877
remaining: Double,
6978
percentRemaining: Double,
7079
quotaId: String,
80+
creditsUsed: Double? = nil,
7181
hasPercentRemaining: Bool = true,
7282
unlimited: Bool = false)
7383
{
7484
self.entitlement = entitlement
7585
self.remaining = remaining
86+
self.creditsUsed = creditsUsed
7687
self.percentRemaining = unlimited ? 100 : percentRemaining
7788
self.quotaId = quotaId
7889
self.hasPercentRemaining = unlimited || hasPercentRemaining
@@ -89,6 +100,7 @@ public struct CopilotUsageResponse: Sendable, Decodable {
89100
self.remaining = decodedRemaining ?? 0
90101
self.entitlementWasDecoded = decodedEntitlement != nil
91102
self.remainingWasDecoded = decodedRemaining != nil
103+
self.creditsUsed = Self.decodeNumberIfPresent(container: container, key: .creditsUsed)
92104
let decodedUnlimited = try container.decodeIfPresent(Bool.self, forKey: .unlimited) ?? false
93105
let decodedPercent = Self.decodeNumberIfPresent(container: container, key: .percentRemaining)
94106
if decodedUnlimited {
@@ -113,6 +125,43 @@ public struct CopilotUsageResponse: Sendable, Decodable {
113125
self.unlimited = decodedUnlimited
114126
}
115127

128+
private init(
129+
entitlement: Double,
130+
remaining: Double,
131+
creditsUsed: Double?,
132+
percentRemaining: Double,
133+
quotaId: String,
134+
hasPercentRemaining: Bool,
135+
unlimited: Bool,
136+
entitlementWasDecoded: Bool,
137+
remainingWasDecoded: Bool)
138+
{
139+
self.entitlement = entitlement
140+
self.remaining = remaining
141+
self.creditsUsed = creditsUsed
142+
self.percentRemaining = percentRemaining
143+
self.quotaId = quotaId
144+
self.hasPercentRemaining = hasPercentRemaining
145+
self.unlimited = unlimited
146+
self.entitlementWasDecoded = entitlementWasDecoded
147+
self.remainingWasDecoded = remainingWasDecoded
148+
}
149+
150+
/// Returns a copy carrying `creditsUsed`, preserving the decoded-flag
151+
/// semantics that placeholder classification depends on.
152+
fileprivate func withCreditsUsed(_ creditsUsed: Double?) -> QuotaSnapshot {
153+
QuotaSnapshot(
154+
entitlement: self.entitlement,
155+
remaining: self.remaining,
156+
creditsUsed: creditsUsed,
157+
percentRemaining: self.percentRemaining,
158+
quotaId: self.quotaId,
159+
hasPercentRemaining: self.hasPercentRemaining,
160+
unlimited: self.unlimited,
161+
entitlementWasDecoded: self.entitlementWasDecoded,
162+
remainingWasDecoded: self.remainingWasDecoded)
163+
}
164+
116165
private static func decodeNumberIfPresent(
117166
container: KeyedDecodingContainer<CodingKeys>,
118167
key: CodingKeys) -> Double?
@@ -185,10 +234,10 @@ public struct CopilotUsageResponse: Sendable, Decodable {
185234
let container = try decoder.container(keyedBy: CodingKeys.self)
186235
var premium = try container.decodeIfPresent(QuotaSnapshot.self, forKey: .premiumInteractions)
187236
var chat = try container.decodeIfPresent(QuotaSnapshot.self, forKey: .chat)
188-
if premium?.isPlaceholder == true {
237+
if premium?.isPlaceholder == true, premium?.carriesCreditsCounter != true {
189238
premium = nil
190239
}
191-
if chat?.isPlaceholder == true {
240+
if chat?.isPlaceholder == true, chat?.carriesCreditsCounter != true {
192241
chat = nil
193242
}
194243

@@ -204,7 +253,7 @@ public struct CopilotUsageResponse: Sendable, Decodable {
204253
guard let decoded = try dynamic.decodeIfPresent(QuotaSnapshot.self, forKey: key) else {
205254
continue
206255
}
207-
guard !decoded.isPlaceholder else { continue }
256+
guard !decoded.isPlaceholder || decoded.carriesCreditsCounter else { continue }
208257
value = decoded
209258
} catch {
210259
continue
@@ -348,8 +397,35 @@ public struct CopilotUsageResponse: Sendable, Decodable {
348397
fallback: QuotaSnapshot?) -> QuotaSnapshot?
349398
{
350399
if direct?.unlimited == true, let fallback = usableQuotaSnapshot(from: fallback) {
351-
return fallback
400+
// The direct snapshot's absolute credit counter is real consumption
401+
// even though its unlimited marker makes it ineligible for a
402+
// percentage window; keep the counter on the selected fallback.
403+
return fallback.withCreditsUsed(direct?.creditsUsed)
404+
}
405+
if let directWindow = self.usableQuotaSnapshot(from: direct) {
406+
return directWindow
407+
}
408+
guard let fallback = self.usableQuotaSnapshot(from: fallback) else {
409+
return nil
352410
}
353-
return self.usableQuotaSnapshot(from: direct) ?? self.usableQuotaSnapshot(from: fallback)
411+
// A zero-entitlement placeholder can still carry a real absolute
412+
// counter; keep it on the selected fallback instead of dropping it.
413+
if direct?.carriesCreditsCounter == true {
414+
return fallback.withCreditsUsed(direct?.creditsUsed)
415+
}
416+
return fallback
417+
}
418+
}
419+
420+
/// Token-billed Copilot seats report consumption as an absolute credit counter
421+
/// rather than a percentage window. Carried separately from rate windows so the
422+
/// value stays accessible without inventing a fake quota denominator.
423+
public struct CopilotCreditsSnapshot: Sendable, Codable, Equatable {
424+
public let creditsUsed: Double
425+
public let quotaResetDate: Date?
426+
427+
public init(creditsUsed: Double, quotaResetDate: Date? = nil) {
428+
self.creditsUsed = creditsUsed
429+
self.quotaResetDate = quotaResetDate
354430
}
355431
}

Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ public struct CopilotUsageFetcher: Sendable {
7171
let chatSnapshot = usage.quotaSnapshots.chat
7272
let premium = Self.makeRateWindow(from: premiumSnapshot, resetsAt: resetsAt)
7373
let chat = Self.makeRateWindow(from: chatSnapshot, resetsAt: resetsAt)
74+
let creditsUsed = premiumSnapshot?.creditsUsed ?? chatSnapshot?.creditsUsed
75+
let copilotCredits = creditsUsed.map {
76+
CopilotCreditsSnapshot(creditsUsed: $0, quotaResetDate: resetsAt)
77+
}
7478
let hasUnlimitedQuota = premiumSnapshot?.unlimited == true || chatSnapshot?.unlimited == true
7579

7680
let primary: RateWindow?
@@ -102,6 +106,7 @@ public struct CopilotUsageFetcher: Sendable {
102106
secondary: secondary,
103107
tertiary: nil,
104108
providerCost: nil,
109+
copilotCredits: copilotCredits,
105110
updatedAt: Date(),
106111
identity: identity)
107112
}

Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable {
145145
public let extraWindowCount: Int
146146
public let providerCostPresent: Bool
147147
public let providerSpecificData: [String]
148+
public let copilotCredits: ProviderDiagnosticCopilotCredits?
148149

149150
private enum CodingKeys: String, CodingKey {
150151
case updatedAt
@@ -153,6 +154,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable {
153154
case extraWindowCount
154155
case providerCostPresent
155156
case providerSpecificData
157+
case copilotCredits
156158
}
157159

158160
public init(from snapshot: UsageSnapshot) {
@@ -187,13 +189,19 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable {
187189
if snapshot.deepgramUsage != nil { providerSpecificData.append("deepgramUsage") }
188190
if snapshot.xaiUsage != nil { providerSpecificData.append("xaiUsage") }
189191
if snapshot.cursorRequests != nil { providerSpecificData.append("cursorRequests") }
192+
if snapshot.copilotCredits != nil { providerSpecificData.append("copilotCredits") }
190193

191194
self.updatedAt = snapshot.updatedAt
192195
self.dataConfidence = snapshot.dataConfidence.rawValue
193196
self.windows = windows
194197
self.extraWindowCount = snapshot.extraRateWindows?.count ?? 0
195198
self.providerCostPresent = snapshot.providerCost != nil
196199
self.providerSpecificData = providerSpecificData.sorted()
200+
self.copilotCredits = snapshot.copilotCredits.map {
201+
ProviderDiagnosticCopilotCredits(
202+
creditsUsed: $0.creditsUsed,
203+
quotaResetDate: $0.quotaResetDate)
204+
}
197205
}
198206

199207
public init(from decoder: Decoder) throws {
@@ -205,6 +213,19 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable {
205213
self.extraWindowCount = try container.decode(Int.self, forKey: .extraWindowCount)
206214
self.providerCostPresent = try container.decode(Bool.self, forKey: .providerCostPresent)
207215
self.providerSpecificData = try container.decode([String].self, forKey: .providerSpecificData)
216+
self.copilotCredits = try container.decodeIfPresent(
217+
ProviderDiagnosticCopilotCredits.self,
218+
forKey: .copilotCredits)
219+
}
220+
}
221+
222+
public struct ProviderDiagnosticCopilotCredits: Codable, Sendable {
223+
public let creditsUsed: Double
224+
public let quotaResetDate: Date?
225+
226+
public init(creditsUsed: Double, quotaResetDate: Date?) {
227+
self.creditsUsed = creditsUsed
228+
self.quotaResetDate = quotaResetDate
208229
}
209230
}
210231

Sources/CodexBarCore/UsageFetcher.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ public struct UsageSnapshot: Codable, Sendable {
171171
public let poeUsage: PoeUsageHistorySnapshot?
172172
public let xaiUsage: XAIUsageSnapshot?
173173
public let cursorRequests: CursorRequestUsage?
174+
public let copilotCredits: CopilotCreditsSnapshot?
174175
/// Live-only marker for optional Command Code subscription lookup failure.
175176
public let commandCodeSubscriptionEnrichmentUnavailable: Bool
176177
/// Live-only marker that Command Code returned a recognized subscription plan.
@@ -247,6 +248,7 @@ public struct UsageSnapshot: Codable, Sendable {
247248
poeUsage: PoeUsageHistorySnapshot? = nil,
248249
xaiUsage: XAIUsageSnapshot? = nil,
249250
cursorRequests: CursorRequestUsage? = nil,
251+
copilotCredits: CopilotCreditsSnapshot? = nil,
250252
commandCodeSubscriptionEnrichmentUnavailable: Bool = false,
251253
commandCodeHasSubscriptionPlan: Bool = false,
252254
commandCodeMonthlyGrantDepleted: Bool = false,
@@ -289,6 +291,7 @@ public struct UsageSnapshot: Codable, Sendable {
289291
self.poeUsage = poeUsage
290292
self.xaiUsage = xaiUsage
291293
self.cursorRequests = cursorRequests
294+
self.copilotCredits = copilotCredits
292295
self.commandCodeSubscriptionEnrichmentUnavailable = commandCodeSubscriptionEnrichmentUnavailable
293296
self.commandCodeHasSubscriptionPlan = commandCodeHasSubscriptionPlan
294297
self.commandCodeMonthlyGrantDepleted = commandCodeMonthlyGrantDepleted
@@ -364,6 +367,7 @@ public struct UsageSnapshot: Codable, Sendable {
364367
self.poeUsage = try container.decodeIfPresent(PoeUsageHistorySnapshot.self, forKey: .poeUsage)
365368
self.xaiUsage = try container.decodeIfPresent(XAIUsageSnapshot.self, forKey: .xaiUsage)
366369
self.cursorRequests = nil // Not persisted, fetched fresh each time
370+
self.copilotCredits = nil // Not persisted, fetched fresh each time
367371
self.commandCodeSubscriptionEnrichmentUnavailable = false // Live-only fetch state
368372
self.commandCodeHasSubscriptionPlan = false // Live-only fetch state
369373
self.commandCodeMonthlyGrantDepleted = false // Live-only fetch state
@@ -603,6 +607,7 @@ public struct UsageSnapshot: Codable, Sendable {
603607
poeUsage: self.poeUsage,
604608
xaiUsage: self.xaiUsage,
605609
cursorRequests: self.cursorRequests,
610+
copilotCredits: self.copilotCredits,
606611
commandCodeSubscriptionEnrichmentUnavailable: self.commandCodeSubscriptionEnrichmentUnavailable,
607612
commandCodeHasSubscriptionPlan: self.commandCodeHasSubscriptionPlan,
608613
commandCodeMonthlyGrantDepleted: self.commandCodeMonthlyGrantDepleted,

Tests/CodexBarTests/CopilotUsageFetcherTests.swift

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,21 @@ struct CopilotUsageFetcherTests {
4242
{
4343
"copilot_plan": "business",
4444
"token_based_billing": true,
45+
"quota_reset_date": "2026-09-01",
4546
"quota_snapshots": {
4647
"premium_interactions": {
4748
"entitlement": 0,
4849
"remaining": 0,
4950
"percent_remaining": 100,
50-
"quota_id": "premium_interactions"
51+
"quota_id": "premium_interactions",
52+
"credits_used": 31
5153
},
5254
"chat": {
5355
"entitlement": 0,
5456
"remaining": 0,
5557
"percent_remaining": 100,
56-
"quota_id": "chat"
58+
"quota_id": "chat",
59+
"credits_used": 0
5760
}
5861
}
5962
}
@@ -66,9 +69,96 @@ struct CopilotUsageFetcherTests {
6669

6770
#expect(snapshot.primary == nil)
6871
#expect(snapshot.secondary == nil)
72+
#expect(snapshot.copilotCredits?.creditsUsed == 31)
73+
#expect(snapshot.copilotCredits?.quotaResetDate != nil)
6974
#expect(snapshot.identity?.loginMethod == "Business")
7075
}
7176

77+
@Test
78+
func `fetch retains token billed credits counter across zero entitlement fallback`() async throws {
79+
let transport = ProviderHTTPTransportStub { request in
80+
#expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder")
81+
let response = try HTTPURLResponse(
82+
url: #require(request.url),
83+
statusCode: 200,
84+
httpVersion: "HTTP/1.1",
85+
headerFields: ["Content-Type": "application/json"])!
86+
let data = Data(
87+
"""
88+
{
89+
"copilot_plan": "business",
90+
"token_based_billing": true,
91+
"quota_reset_date": "2026-09-01",
92+
"monthly_quotas": { "completions": 300 },
93+
"limited_user_quotas": { "completions": 75 },
94+
"quota_snapshots": {
95+
"premium_interactions": {
96+
"entitlement": 0,
97+
"remaining": 0,
98+
"percent_remaining": 100,
99+
"quota_id": "premium_interactions",
100+
"credits_used": 31
101+
}
102+
}
103+
}
104+
""".utf8)
105+
return (data, response)
106+
}
107+
let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport)
108+
109+
let snapshot = try await fetcher.fetch()
110+
111+
#expect(snapshot.primary?.usedPercent == 75)
112+
#expect(snapshot.copilotCredits?.creditsUsed == 31)
113+
}
114+
115+
@Test
116+
func `fetch retains token billed credits counter across monthly quota fallback`() async throws {
117+
let transport = ProviderHTTPTransportStub { request in
118+
#expect(request.value(forHTTPHeaderField: "Authorization") == "token test-token-placeholder")
119+
let response = try HTTPURLResponse(
120+
url: #require(request.url),
121+
statusCode: 200,
122+
httpVersion: "HTTP/1.1",
123+
headerFields: ["Content-Type": "application/json"])!
124+
let data = Data(
125+
"""
126+
{
127+
"copilot_plan": "business",
128+
"token_based_billing": true,
129+
"quota_reset_date": "2026-09-01",
130+
"monthly_quotas": { "completions": 300 },
131+
"limited_user_quotas": { "completions": 75 },
132+
"quota_snapshots": {
133+
"premium_interactions": {
134+
"unlimited": true,
135+
"entitlement": 0,
136+
"remaining": 0,
137+
"percent_remaining": 100,
138+
"quota_id": "premium_interactions",
139+
"credits_used": 31
140+
},
141+
"chat": {
142+
"unlimited": true,
143+
"entitlement": 0,
144+
"remaining": 0,
145+
"percent_remaining": 100,
146+
"quota_id": "chat",
147+
"credits_used": 0
148+
}
149+
}
150+
}
151+
""".utf8)
152+
return (data, response)
153+
}
154+
let fetcher = CopilotUsageFetcher(token: "test-token-placeholder", transport: transport)
155+
156+
let snapshot = try await fetcher.fetch()
157+
158+
#expect(snapshot.primary?.usedPercent == 75)
159+
#expect(snapshot.copilotCredits?.creditsUsed == 31)
160+
}
161+
72162
@Test
73163
func `fetch omits explicitly unlimited only chat quota without failing`() async throws {
74164
let transport = ProviderHTTPTransportStub { request in

0 commit comments

Comments
 (0)