Skip to content
Closed
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
32 changes: 22 additions & 10 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ struct UsageMenuCardView: View {
}

struct TokenUsageSection: Sendable {
let title: String
let sessionLine: String
let monthLine: String
let hintLine: String?
Expand Down Expand Up @@ -150,7 +151,7 @@ struct UsageMenuCardView: View {
}
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text("Cost")
Text(tokenUsage.title)
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
Expand Down Expand Up @@ -507,7 +508,7 @@ private struct CreditsBarContent: View {

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Credits")
Text("Credits remaining")
.font(.body)
.fontWeight(.medium)
if let percentLeft {
Expand Down Expand Up @@ -555,7 +556,7 @@ struct UsageMenuCardCostSectionView: View {
VStack(alignment: .leading, spacing: 10) {
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text("Cost")
Text(tokenUsage.title)
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
Expand Down Expand Up @@ -1104,13 +1105,13 @@ extension UsageMenuCardView.Model {
guard enabled else { return nil }
guard let snapshot else { return nil }

let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
let latestDayCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
let latestDayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
let sessionLine: String = {
if let sessionTokens {
return "Today: \(sessionCost) · \(sessionTokens) tokens"
if let latestDayTokens {
return "Latest day: \(latestDayCost) · \(latestDayTokens) tokens"
}
return "Today: \(sessionCost)"
return "Latest day: \(latestDayCost)"
}()

let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
Expand All @@ -1124,10 +1125,21 @@ extension UsageMenuCardView.Model {
return "Last 30 days: \(monthCost)"
}()
let err = (error?.isEmpty ?? true) ? nil : error
let hintLine: String? = switch provider {
case .codex:
"Estimated from local Codex logs; separate from OpenAI dashboard credits."
case .claude:
"Estimated from local Claude logs; separate from Claude extra-usage billing."
case .vertexai:
"Estimated from local Claude-family logs."
default:
nil
}
return TokenUsageSection(
title: "Local token estimate",
sessionLine: sessionLine,
monthLine: monthLine,
hintLine: nil,
hintLine: hintLine,
errorLine: err,
errorCopyText: (error?.isEmpty ?? true) ? nil : error)
}
Expand All @@ -1148,7 +1160,7 @@ extension UsageMenuCardView.Model {
used = String(format: "%.0f", cost.used)
limit = String(format: "%.0f", cost.limit)
} else {
title = "Extra usage"
title = "Extra usage (billing)"
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1623,13 +1623,13 @@ extension UsageStore {
return
}
let duration = Date().timeIntervalSince(startedAt)
let sessionCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—"
let latestDayCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—"
let monthCost = snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—"
let durationText = String(format: "%.2f", duration)
let message =
"cost usage success provider=\(providerText) " +
"duration=\(durationText)s " +
"today=\(sessionCost) " +
"latestDay=\(latestDayCost) " +
"30d=\(monthCost)"
self.tokenCostLogger.info(message)
self.tokenSnapshots[provider] = snapshot
Expand Down
9 changes: 5 additions & 4 deletions Sources/CodexBarCLI/CLICostCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,16 @@ extension CodexBarCLI {
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
let header = Self.costHeaderLine("\(name) Cost (local)", useColor: useColor)

let todayCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
let todayLine = todayTokens.map { "Today: \(todayCost) · \($0) tokens" } ?? "Today: \(todayCost)"
let latestDayCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
let latestDayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
let latestDayLine = latestDayTokens.map { "Latest day: \(latestDayCost) · \($0) tokens" }
?? "Latest day: \(latestDayCost)"

let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
let monthTokens = snapshot.last30DaysTokens.map { UsageFormatter.tokenCountString($0) }
let monthLine = monthTokens.map { "Last 30 days: \(monthCost) · \($0) tokens" } ?? "Last 30 days: \(monthCost)"

return [header, todayLine, monthLine].joined(separator: "\n")
return [header, latestDayLine, monthLine].joined(separator: "\n")
}

private static func costHeaderLine(_ header: String, useColor: Bool) -> String {
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public struct CostUsageFetcher: Sendable {
}

static func tokenSnapshot(from daily: CostUsageDailyReport, now: Date) -> CostUsageTokenSnapshot {
// Pick the most recent day; break ties by cost/tokens to keep a stable "session" row.
// Pick the most recent day; break ties by cost/tokens to keep a stable latest-day row.
let currentDay = daily.data.max { lhs, rhs in
let lDate = CostUsageDateParser.parse(lhs.date) ?? .distantPast
let rDate = CostUsageDateParser.parse(rhs.date) ?? .distantPast
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/CostUsageModels.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Foundation

public struct CostUsageTokenSnapshot: Sendable, Equatable {
/// Legacy field name: this is the most recent daily aggregate, not a live session total.
public let sessionTokens: Int?
/// Legacy field name: this is the most recent daily aggregate, not a live session total.
public let sessionCostUSD: Double?
public let last30DaysTokens: Int?
public let last30DaysCostUSD: Double?
Expand Down
65 changes: 62 additions & 3 deletions Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,58 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
return nil
}

private static func reconcileProviderCost(
primary: ProviderCostSnapshot?,
web: ProviderCostSnapshot?) -> ProviderCostSnapshot?
{
guard let web else { return primary }
guard let primary else { return web }

if primary.currencyCode != web.currencyCode {
Self.log.info(
"Claude extra usage mismatch; preferring web cost",
metadata: [
"reason": "currency",
"primaryCurrency": primary.currencyCode,
"webCurrency": web.currencyCode,
])
return web
}

if Self.providerCostDiffLooksLikeUnitMismatch(primary: primary, web: web) {
Self.log.info(
"Claude extra usage mismatch; preferring web cost",
metadata: [
"reason": "unitMismatch",
"primaryUsed": String(primary.used),
"primaryLimit": String(primary.limit),
"webUsed": String(web.used),
"webLimit": String(web.limit),
])
return web
}

return primary
}

private static func providerCostDiffLooksLikeUnitMismatch(
primary: ProviderCostSnapshot,
web: ProviderCostSnapshot) -> Bool
{
Self.amountLooksLikeUnitMismatch(primary.used, web.used)
|| Self.amountLooksLikeUnitMismatch(primary.limit, web.limit)
}

private static func amountLooksLikeUnitMismatch(_ lhs: Double, _ rhs: Double) -> Bool {
let left = abs(lhs)
let right = abs(rhs)
guard left > 0, right > 0 else {
return abs(left - right) >= 1
}
let ratio = max(left, right) / min(left, right)
return ratio >= 50 && abs(left - right) >= 1
}

// MARK: - Web API path (uses browser cookies)

private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot {
Expand Down Expand Up @@ -831,13 +883,13 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
Self.log.debug(msg)
}
}
// Only merge cost extras; keep identity fields from the primary data source.
if snapshot.providerCost == nil, let extra = webData.extraUsageCost {
let providerCost = Self.reconcileProviderCost(primary: snapshot.providerCost, web: webData.extraUsageCost)
if providerCost != snapshot.providerCost {
return ClaudeUsageSnapshot(
primary: snapshot.primary,
secondary: snapshot.secondary,
opus: snapshot.opus,
providerCost: extra,
providerCost: providerCost,
updatedAt: snapshot.updatedAt,
accountEmail: snapshot.accountEmail,
accountOrganization: snapshot.accountOrganization,
Expand Down Expand Up @@ -1024,5 +1076,12 @@ extension ClaudeUsageFetcher {
rateLimitTier: rateLimitTier)
return try Self.mapOAuthUsage(usage, credentials: creds)
}

public static func _reconcileProviderCostForTesting(
primary: ProviderCostSnapshot?,
web: ProviderCostSnapshot?) -> ProviderCostSnapshot?
{
Self.reconcileProviderCost(primary: primary, web: web)
}
}
#endif
8 changes: 5 additions & 3 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Foundation

enum CostUsageCacheIO {
static let currentVersion = 2

private static func defaultCacheRoot() -> URL {
let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
return root.appendingPathComponent("CodexBar", isDirectory: true)
Expand All @@ -10,7 +12,7 @@ enum CostUsageCacheIO {
let root = cacheRoot ?? self.defaultCacheRoot()
return root
.appendingPathComponent("cost-usage", isDirectory: true)
.appendingPathComponent("\(provider.rawValue)-v1.json", isDirectory: false)
.appendingPathComponent("\(provider.rawValue)-v\(Self.currentVersion).json", isDirectory: false)
}

static func load(provider: UsageProvider, cacheRoot: URL? = nil) -> CostUsageCache {
Expand All @@ -23,7 +25,7 @@ enum CostUsageCacheIO {
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data)
else { return nil }
guard decoded.version == 1 else { return nil }
guard decoded.version == Self.currentVersion else { return nil }
return decoded
}

Expand All @@ -44,7 +46,7 @@ enum CostUsageCacheIO {
}

struct CostUsageCache: Codable, Sendable {
var version: Int = 1
var version: Int = CostUsageCacheIO.currentVersion
var lastScanUnixMs: Int64 = 0

/// filePath -> file usage
Expand Down
33 changes: 30 additions & 3 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ enum CostUsagePricing {
inputCostPerToken: 1.25e-6,
outputCostPerToken: 1e-5,
cacheReadInputCostPerToken: 1.25e-7),
"gpt-5.1-codex-mini": CodexPricing(
inputCostPerToken: 2.5e-7,
outputCostPerToken: 2e-6,
cacheReadInputCostPerToken: 2.5e-8),
"gpt-5.2": CodexPricing(
inputCostPerToken: 1.75e-6,
outputCostPerToken: 1.4e-5,
Expand Down Expand Up @@ -122,6 +126,16 @@ enum CostUsagePricing {
outputCostPerTokenAboveThreshold: 2.25e-5,
cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6,
cacheReadInputCostPerTokenAboveThreshold: 6e-7),
"claude-sonnet-4-6": ClaudePricing(
inputCostPerToken: 3e-6,
outputCostPerToken: 1.5e-5,
cacheCreationInputCostPerToken: 3.75e-6,
cacheReadInputCostPerToken: 3e-7,
thresholdTokens: 200_000,
inputCostPerTokenAboveThreshold: 6e-6,
outputCostPerTokenAboveThreshold: 2.25e-5,
cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6,
cacheReadInputCostPerTokenAboveThreshold: 6e-7),
"claude-sonnet-4-5-20250929": ClaudePricing(
inputCostPerToken: 3e-6,
outputCostPerToken: 1.5e-5,
Expand Down Expand Up @@ -169,9 +183,18 @@ enum CostUsagePricing {
if trimmed.hasPrefix("openai/") {
trimmed = String(trimmed.dropFirst("openai/".count))
}
if let codexRange = trimmed.range(of: "-codex") {
let base = String(trimmed[..<codexRange.lowerBound])
if self.codex[base] != nil { return base }
let aliases: [String: String] = [
"gpt-5-codex": "gpt-5",
"gpt-5.1-codex": "gpt-5.1",
"gpt-5.1-codex-max": "gpt-5.1",
"gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
"gpt-5.2-codex": "gpt-5.2",
"gpt-5.3-codex": "gpt-5.3",
"gpt-5.3-codex-max": "gpt-5.3",
"codex-mini-latest": "gpt-5.1-codex-mini",
]
if let alias = aliases[trimmed] {
return alias
}
return trimmed
}
Expand All @@ -191,6 +214,10 @@ enum CostUsagePricing {
}
}

if trimmed.hasPrefix("claude-"), trimmed.contains("@") {
trimmed = trimmed.replacingOccurrences(of: "@", with: "-")
}

if let vRange = trimmed.range(of: #"-v\d+:\d+$"#, options: .regularExpression) {
trimmed.removeSubrange(vRange)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarWidget/CodexBarWidgetProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ enum CompactMetric: String, AppEnum {

static let caseDisplayRepresentations: [CompactMetric: DisplayRepresentation] = [
.credits: DisplayRepresentation(title: "Credits left"),
.todayCost: DisplayRepresentation(title: "Today cost"),
.todayCost: DisplayRepresentation(title: "Latest day cost"),
.last30DaysCost: DisplayRepresentation(title: "30d cost"),
]
}
Expand Down
12 changes: 6 additions & 6 deletions Sources/CodexBarWidget/CodexBarWidgetViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ private struct CompactMetricView: View {
case .todayCost:
let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "—"
let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount)
return (value, "Today cost", detail)
return (value, "Latest day cost", detail)
case .last30DaysCost:
let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "—"
let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount)
Expand Down Expand Up @@ -324,7 +324,7 @@ private struct SwitcherMediumUsageView: View {
}
if let token = entry.tokenUsage {
ValueLine(
title: "Today",
title: "Latest day",
value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
}
}
Expand Down Expand Up @@ -356,7 +356,7 @@ private struct SwitcherLargeUsageView: View {
if let token = entry.tokenUsage {
VStack(alignment: .leading, spacing: 4) {
ValueLine(
title: "Today",
title: "Latest day",
value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
ValueLine(
title: "30d",
Expand Down Expand Up @@ -415,7 +415,7 @@ private struct MediumUsageView: View {
}
if let token = entry.tokenUsage {
ValueLine(
title: "Today",
title: "Latest day",
value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
}
}
Expand Down Expand Up @@ -449,7 +449,7 @@ private struct LargeUsageView: View {
if let token = entry.tokenUsage {
VStack(alignment: .leading, spacing: 4) {
ValueLine(
title: "Today",
title: "Latest day",
value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
ValueLine(
title: "30d",
Expand All @@ -476,7 +476,7 @@ private struct HistoryView: View {
.frame(height: self.isLarge ? 90 : 60)
if let token = entry.tokenUsage {
ValueLine(
title: "Today",
title: "Latest day",
value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
ValueLine(
title: "30d",
Expand Down
2 changes: 1 addition & 1 deletion Tests/CodexBarTests/CLICostTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ struct CLICostTests {
.replacingOccurrences(of: "$ ", with: "$")

#expect(output.contains("Claude Cost (local)"))
#expect(output.contains("Today: $1.25 · 1.2K tokens"))
#expect(output.contains("Latest day: $1.25 · 1.2K tokens"))
#expect(output.contains("Last 30 days: $9.99 · 9K tokens"))
}

Expand Down
Loading