Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
854090a
feat(core): generalized usage/spend scanning + pricing foundation
Yuxin-Qiao Jul 31, 2026
c338e8f
Keep Groq out of the cost-capable set in the foundation layer
Yuxin-Qiao Jul 31, 2026
c565404
feat(core): registerable local-history scanner registry + zcode/trae/…
Yuxin-Qiao Aug 1, 2026
1bba0c5
feat(core): unified usage-event layer + shared aggregator, add Copilot
Yuxin-Qiao Aug 1, 2026
3eb62c2
style(tests): apply SwiftFormat to new engine/copilot suites
Yuxin-Qiao Aug 1, 2026
6cfcb0c
feat(core): full-history manual rescan + live scan progress
Yuxin-Qiao Aug 1, 2026
9792595
fix(i18n): translate "Scanning history" into Italian
Yuxin-Qiao Aug 1, 2026
5c2ae86
fix(core): provider ownership + complete-cost + cache-write pricing
Yuxin-Qiao Aug 1, 2026
2d25dab
fix(core): prefer refreshed catalog rates over embedded Gemini table
Yuxin-Qiao Aug 1, 2026
d5f4385
fix(core): fall back to OpenCode's time_created column
Yuxin-Qiao Aug 1, 2026
0adc8a1
feat(core): wire local history scanners into the dashboard loader
Yuxin-Qiao Aug 1, 2026
1d68546
refactor(core): migrate Kimi/MiniMax/Antigravity scanners to the shar…
Yuxin-Qiao Aug 1, 2026
b7110f4
feat(core): retain OpenCode billing ownership evidence end to end
Yuxin-Qiao Aug 1, 2026
f719adf
style: satisfy swift-format lint-macos gate
Yuxin-Qiao Aug 1, 2026
3a22838
fix(core): wire local-history providers into the token refresh pipeline
Yuxin-Qiao Aug 1, 2026
204f1eb
fix(core): preserve billing ownership across merged daily reports
Yuxin-Qiao Aug 1, 2026
65af90d
fix(core): withhold OpenCode headline cost on unpriced days and mark …
Yuxin-Qiao Aug 1, 2026
10fb613
fix(core): omit Trae activity outside the requested history window
Yuxin-Qiao Aug 1, 2026
7ef4935
fix(core): lift the per-file cap for full Codex rescans and bridge ca…
Yuxin-Qiao Aug 1, 2026
b366ef8
fix(core): overflow-safe total checks and Copilot rollup request counts
Yuxin-Qiao Aug 1, 2026
20c8725
fix(scanner): prefer recent OpenCode files before the scan cap
Yuxin-Qiao Aug 2, 2026
40949c0
fix(core): keep outer billing route and withhold partial window costs
Yuxin-Qiao Aug 2, 2026
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
8 changes: 7 additions & 1 deletion Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,8 @@ public struct CostUsageFetcher: Sendable {
historyDays: historyDays,
useCurrentLocalDayForSession: true,
meteredCostUSD: report.meteredCostUSD,
// The Cursor dashboard API returns the account's actual billed usage events.
costSource: .providerReported,
credentialScopeFingerprint: report.credentialScopeFingerprint)
}
#endif
Expand All @@ -751,6 +753,7 @@ public struct CostUsageFetcher: Sendable {
useCurrentLocalDayForSession: Bool = true,
calendar: Calendar = .current,
meteredCostUSD: Double? = nil,
costSource: CostUsageCostSource = .estimated,
credentialScopeFingerprint: String? = nil,
historyLabel: String? = nil,
projects: [CostUsageProjectBreakdown] = [],
Expand Down Expand Up @@ -791,6 +794,7 @@ public struct CostUsageFetcher: Sendable {
historyDays: historyDays,
historyLabel: historyLabel,
meteredCostUSD: meteredCostUSD,
costSource: costSource,
credentialScopeFingerprint: credentialScopeFingerprint,
daily: daily.data,
projects: projects,
Expand Down Expand Up @@ -1030,7 +1034,9 @@ extension CostUsageFetcher {
from: daily,
now: now,
historyDays: historyDays,
useCurrentLocalDayForSession: false)
useCurrentLocalDayForSession: false,
// Bedrock Cost Explorer reports actual billed spend, not a rate-card estimate.
costSource: .providerReported)
}

#if os(macOS)
Expand Down
162 changes: 162 additions & 0 deletions Sources/CodexBarCore/CostUsageModels.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
import Foundation

/// Extracts billing ownership only from an explicit provider namespace retained in a model id.
///
/// A plain family label such as `MiniMax-M3` is not evidence because a subscription can bundle
/// that model. A namespaced id such as `minimax/MiniMax-M3` is routing evidence and can safely be
/// carried into the dashboard without tool-specific heuristics.
public enum CostUsageBillingProvider {
public static func providerID(fromNamespacedModel model: String) -> String? {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
let components = trimmed
.split(separator: "/", omittingEmptySubsequences: true)
.dropLast()
.map { $0.lowercased() }
guard !components.isEmpty else {
return nil
}
let aliases: [String: String] = [
"alibaba": UsageProvider.qwencloud.rawValue,
"alibabacloud": UsageProvider.qwencloud.rawValue,
"anthropic": UsageProvider.claude.rawValue,
"google": UsageProvider.gemini.rawValue,
"minimax-cn": UsageProvider.minimax.rawValue,
"moonshot": UsageProvider.moonshot.rawValue,
"moonshotai": UsageProvider.moonshot.rawValue,
"openai": UsageProvider.openai.rawValue,
"qwen": UsageProvider.qwencloud.rawValue,
"z.ai": UsageProvider.zai.rawValue,
]
for namespace in components.reversed() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the outer billing provider for nested routes

When a model ID contains a routing provider plus a vendor namespace, such as openrouter/anthropic/claude-*, iterating the namespaces in reverse selects Anthropic and attributes the breakdown to Claude even though OpenRouter owns the billed request. Prefer the outer explicit route, or withhold ownership when the namespace chain is ambiguous, so routed usage is not displayed under a different provider.

AGENTS.md reference: AGENTS.md:L46-L46

Useful? React with 👍 / 👎.

if let alias = aliases[namespace] { return alias }
if let provider = UsageProvider(rawValue: namespace) { return provider.rawValue }
}
return nil
}
}

/// Where a snapshot's `costUSD` figures come from. Local scanners price token counts against
/// models.dev rate cards, so their cost is an API-rate *estimate* of the real bill; provider
/// dashboards/APIs (Cursor usage events, Bedrock Cost Explorer) report the actual billed amount.
public enum CostUsageCostSource: String, Sendable, Equatable {
/// Billed spend as reported by the provider itself.
case providerReported
/// Locally estimated spend (token counts priced against public rate cards).
case estimated
}

public struct CostUsageWindowSummary: Sendable, Equatable {
public let days: Int
public let totalTokens: Int?
Expand Down Expand Up @@ -77,6 +122,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable {
/// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor)
/// report this; `nil` when unknown.
public let meteredCostUSD: Double?
/// Origin of the cost figures in this snapshot. Defaults to `.estimated` because most
/// snapshots are priced locally; provider-billed sources opt into `.providerReported`.
public let costSource: CostUsageCostSource
/// Internal credential scope used to prevent cross-account cache publication. This is a
/// non-reversible fingerprint, not account identity, and is not emitted by CLI payloads.
public let credentialScopeFingerprint: String?
Expand All @@ -97,6 +145,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable {
historyCoverageIsEstablished: Bool = true,
historyLabel: String? = nil,
meteredCostUSD: Double? = nil,
costSource: CostUsageCostSource = .estimated,
credentialScopeFingerprint: String? = nil,
daily: [CostUsageDailyReport.Entry],
projects: [CostUsageProjectBreakdown] = [],
Expand All @@ -115,6 +164,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable {
self.historyCoverageIsEstablished = historyCoverageIsEstablished
self.historyLabel = historyLabel
self.meteredCostUSD = meteredCostUSD
self.costSource = costSource
self.credentialScopeFingerprint = credentialScopeFingerprint
self.daily = daily
self.projects = projects
Expand Down Expand Up @@ -275,8 +325,21 @@ public struct CostUsageProjectSourceBreakdown: Sendable, Equatable {
public struct CostUsageDailyReport: Sendable, Decodable {
public struct ModelBreakdown: Sendable, Decodable, Equatable {
public let modelName: String
/// Provider/endpoint identity reported by the source record. This is
/// billing evidence, unlike a model-name guess. It is intentionally
/// optional because many historical formats do not retain routing.
public let billingProviderID: String?
public let costUSD: Double?
public let totalTokens: Int?
public let inputTokens: Int?
public let cacheReadTokens: Int?
public let cacheCreationTokens: Int?
public let outputTokens: Int?
/// Reasoning ("thinking") tokens, when the source format reports them separately
/// (Codex `reasoning_output_tokens`, Gemini `thoughts`, OpenCode `reasoning`).
/// Reasoning is always a sub-bucket of `outputTokens` — output stays billing-inclusive,
/// so reasoning must never be added on top of output when summing buckets.
public let reasoningTokens: Int?
public let requestCount: Int?
public let standardCostUSD: Double?
public let priorityCostUSD: Double?
Expand All @@ -285,9 +348,19 @@ public struct CostUsageDailyReport: Sendable, Decodable {

private enum CodingKeys: String, CodingKey {
case modelName
case billingProviderID
case providerID
case costUSD
case cost
case totalTokens
case inputTokens
case cacheReadTokens
case cacheCreationTokens
case cacheReadInputTokens
case cacheCreationInputTokens
case outputTokens
case reasoningTokens
case reasoningOutputTokens = "reasoning_output_tokens"
case requestCount
case requests
case standardCostUSD
Expand All @@ -299,10 +372,24 @@ public struct CostUsageDailyReport: Sendable, Decodable {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.modelName = try container.decode(String.self, forKey: .modelName)
self.billingProviderID =
try container.decodeIfPresent(String.self, forKey: .billingProviderID)
?? container.decodeIfPresent(String.self, forKey: .providerID)
self.costUSD =
try container.decodeIfPresent(Double.self, forKey: .costUSD)
?? container.decodeIfPresent(Double.self, forKey: .cost)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens)
self.cacheReadTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens)
?? container.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens)
self.cacheCreationTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens)
?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens)
self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens)
self.reasoningTokens =
try container.decodeIfPresent(Int.self, forKey: .reasoningTokens)
?? container.decodeIfPresent(Int.self, forKey: .reasoningOutputTokens)
self.requestCount =
try container.decodeIfPresent(Int.self, forKey: .requestCount)
?? container.decodeIfPresent(Int.self, forKey: .requests)
Expand All @@ -314,17 +401,33 @@ public struct CostUsageDailyReport: Sendable, Decodable {

public init(
modelName: String,
billingProviderID: String? = nil,
costUSD: Double?,
totalTokens: Int? = nil,
inputTokens: Int? = nil,
cacheReadTokens: Int? = nil,
cacheCreationTokens: Int? = nil,
outputTokens: Int? = nil,
reasoningTokens: Int? = nil,
requestCount: Int? = nil,
standardCostUSD: Double? = nil,
priorityCostUSD: Double? = nil,
standardTokens: Int? = nil,
priorityTokens: Int? = nil)
{
self.modelName = modelName
let normalizedProviderID = billingProviderID?
.trimmingCharacters(in: .whitespacesAndNewlines)
self.billingProviderID = normalizedProviderID?.isEmpty == false
? normalizedProviderID
: nil
self.costUSD = costUSD
self.totalTokens = totalTokens
self.inputTokens = inputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheCreationTokens = cacheCreationTokens
self.outputTokens = outputTokens
self.reasoningTokens = reasoningTokens
self.requestCount = requestCount
self.standardCostUSD = standardCostUSD
self.priorityCostUSD = priorityCostUSD
Expand Down Expand Up @@ -530,6 +633,21 @@ extension CostUsageDailyReport {
private struct BreakdownAccumulator {
var totalTokens: Int = 0
var sawTotalTokens = false
var inputTokens: Int = 0
var sawInputTokens = false
var missingInputTokens = false
var cacheReadTokens: Int = 0
var sawCacheReadTokens = false
var missingCacheReadTokens = false
var cacheCreationTokens: Int = 0
var sawCacheCreationTokens = false
var missingCacheCreationTokens = false
var outputTokens: Int = 0
var sawOutputTokens = false
var missingOutputTokens = false
var reasoningTokens: Int = 0
var sawReasoningTokens = false
var missingReasoningTokens = false
var costUSD: Double = 0
var sawCost = false
var standardCostUSD: Double = 0
Expand All @@ -546,6 +664,36 @@ extension CostUsageDailyReport {
self.totalTokens += totalTokens
self.sawTotalTokens = true
}
if let inputTokens = breakdown.inputTokens {
self.inputTokens += inputTokens
self.sawInputTokens = true
} else {
self.missingInputTokens = true
}
if let cacheReadTokens = breakdown.cacheReadTokens {
self.cacheReadTokens += cacheReadTokens
self.sawCacheReadTokens = true
} else {
self.missingCacheReadTokens = true
}
if let cacheCreationTokens = breakdown.cacheCreationTokens {
self.cacheCreationTokens += cacheCreationTokens
self.sawCacheCreationTokens = true
} else {
self.missingCacheCreationTokens = true
}
if let outputTokens = breakdown.outputTokens {
self.outputTokens += outputTokens
self.sawOutputTokens = true
} else {
self.missingOutputTokens = true
}
if let reasoningTokens = breakdown.reasoningTokens {
self.reasoningTokens += reasoningTokens
self.sawReasoningTokens = true
} else {
self.missingReasoningTokens = true
}
if let costUSD = breakdown.costUSD {
self.costUSD += costUSD
self.sawCost = true
Expand Down Expand Up @@ -573,6 +721,13 @@ extension CostUsageDailyReport {
modelName: modelName,
costUSD: self.sawCost ? self.costUSD : nil,
totalTokens: self.sawTotalTokens ? self.totalTokens : nil,
inputTokens: self.sawInputTokens && !self.missingInputTokens ? self.inputTokens : nil,
Comment on lines 738 to +742

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve billing ownership while merging breakdowns

When any report containing an explicit billingProviderID passes through CostUsageDailyReport.merged, this accumulator rebuilds the breakdown without retaining that field, so even a single unambiguous provider becomes nil. This loses the structured ownership needed to attribute namespaced third-party usage correctly; accumulate the IDs and preserve the value when they agree.

AGENTS.md reference: AGENTS.md:L46-L46

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 204f1eb. BreakdownAccumulator now retains billingProviderID and keeps it when merged sources agree, drops it when they conflict, and never lets a source without evidence erase another source's evidence. Covered by CostUsageDailyReportMergeTests.

cacheReadTokens: self.sawCacheReadTokens && !self.missingCacheReadTokens ? self.cacheReadTokens : nil,
cacheCreationTokens: self.sawCacheCreationTokens && !self.missingCacheCreationTokens
? self.cacheCreationTokens
: nil,
outputTokens: self.sawOutputTokens && !self.missingOutputTokens ? self.outputTokens : nil,
reasoningTokens: self.sawReasoningTokens && !self.missingReasoningTokens ? self.reasoningTokens : nil,
standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil,
priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil,
standardTokens: self.sawStandardTokens ? self.standardTokens : nil,
Expand Down Expand Up @@ -1064,6 +1219,13 @@ enum CostUsageBucketInterval {
}

enum CostUsageLocalDay {
static func gregorianCalendar(preserving calendar: Calendar) -> Calendar {
var normalized = Calendar(identifier: .gregorian)
normalized.timeZone = calendar.timeZone
normalized.locale = calendar.locale
return normalized
}

static func gregorianCalendar(matching calendar: Calendar = .current) -> Calendar {
var gregorian = Calendar(identifier: .gregorian)
gregorian.timeZone = calendar.timeZone
Expand Down
9 changes: 8 additions & 1 deletion Sources/CodexBarCore/CostUsageScanExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,14 @@ public enum CostUsageScanExecutor {
guard state.install(continuation) else { return }
queue.async {
guard state.begin() else { return }
state.complete(with: Result { try work(checkCancellation) })
#if canImport(ObjectiveC)
let result = autoreleasepool {
Result { try work(checkCancellation) }
}
#else
let result = Result { try work(checkCancellation) }
#endif
state.complete(with: result)
}
}
} onCancel: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "3aa49b47f4b78e13"
static let value = "c76ca2e79b9ed330"
}
9 changes: 6 additions & 3 deletions Sources/CodexBarCore/PiSessionCostScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,7 @@ enum PiSessionCostScanner {
pricingKey: CostUsagePricingKey.codex(
modelsDevArtifact: modelsDevArtifact,
formulaVersion: Self.costFormulaVersion,
parserHash: CodexParserHash.value,
modelsDevProviderIDs: ["anthropic", "openai"]))
parserHash: CodexParserHash.value))
}

private static func requestedWindowExpandsCache(
Expand Down Expand Up @@ -942,7 +941,11 @@ extension PiSessionCostScanner {
breakdown.append(CostUsageDailyReport.ModelBreakdown(
modelName: modelName,
costUSD: costNanos.map { Double($0) / Self.costScale },
totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil))
totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil,
inputTokens: packed.inputTokens,
cacheReadTokens: packed.cacheReadTokens,
cacheCreationTokens: packed.cacheWriteTokens,
outputTokens: packed.outputTokens))
dayInput += packed.inputTokens
dayOutput += packed.outputTokens
dayCacheRead += packed.cacheReadTokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public enum AntigravityProviderDescriptor {
]),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
localHistorySources: [.antigravity],
noDataMessage: { "Antigravity cost summary is not supported." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .cli, .oauth],
Expand Down
Loading
Loading