Skip to content
Merged
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
45 changes: 33 additions & 12 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -965,18 +965,6 @@ extension UsageMenuCardView.Model {
percentStyle: percentStyle,
zaiTimeDetail: zaiTimeDetail))
}
if input.provider == .kilo,
metrics.contains(where: { $0.id == "primary" }),
metrics.contains(where: { $0.id == "secondary" })
{
metrics.sort { lhs, rhs in
let kiloOrder: [String: Int] = [
"secondary": 0,
"primary": 1,
]
return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max)
}
}
if input.metadata.supportsOpus, let opus = snapshot.tertiary {
var tertiaryDetailText: String?
if input.provider == .alibaba,
Expand Down Expand Up @@ -1004,6 +992,39 @@ extension UsageMenuCardView.Model {
pacePercent: nil,
paceOnTop: true))
}
if let extraRateWindows = snapshot.extraRateWindows {
metrics.append(contentsOf: extraRateWindows.map { namedWindow in
Metric(
id: namedWindow.id,
title: namedWindow.title,
percent: Self.clamped(
input.usageBarsShowUsed
? namedWindow.window.usedPercent
: namedWindow.window.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(
for: namedWindow.window,
style: input.resetTimeDisplayStyle,
now: input.now),
detailText: nil,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true)
})
}
if input.provider == .kilo,
metrics.contains(where: { $0.id == "primary" }),
metrics.contains(where: { $0.id == "secondary" })
{
metrics.sort { lhs, rhs in
let kiloOrder: [String: Int] = [
"secondary": 0,
"primary": 1,
]
return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max)
}
}

if let codexProjection = input.codexProjection,
codexProjection.supplementalMetrics.contains(.codeReview),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,96 @@ struct OAuthUsageResponse: Decodable {
let sevenDayOAuthApps: OAuthUsageWindow?
let sevenDayOpus: OAuthUsageWindow?
let sevenDaySonnet: OAuthUsageWindow?
let sevenDayDesign: OAuthUsageWindow?
let sevenDayRoutines: OAuthUsageWindow?
let sevenDayDesignSourceKey: String?
let sevenDayRoutinesSourceKey: String?
let iguanaNecktie: OAuthUsageWindow?
let extraUsage: OAuthExtraUsage?

enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDayOAuthApps = "seven_day_oauth_apps"
case sevenDayOpus = "seven_day_opus"
case sevenDaySonnet = "seven_day_sonnet"
case iguanaNecktie = "iguana_necktie"
case extraUsage = "extra_usage"
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
self.fiveHour = Self.decodeWindow(in: container, keys: ["five_hour"])
self.sevenDay = Self.decodeWindow(in: container, keys: ["seven_day"])
self.sevenDayOAuthApps = Self.decodeWindow(in: container, keys: ["seven_day_oauth_apps"])
self.sevenDayOpus = Self.decodeWindow(in: container, keys: ["seven_day_opus"])
self.sevenDaySonnet = Self.decodeWindow(in: container, keys: ["seven_day_sonnet"])
let design = Self.decodeWindowWithSource(in: container, keys: [
"seven_day_design",
"seven_day_claude_design",
"claude_design",
"design",
"seven_day_omelette",
"omelette",
"omelette_promotional",
])
self.sevenDayDesign = design.window
self.sevenDayDesignSourceKey = design.sourceKey
let routines = Self.decodeWindowWithSource(in: container, keys: [
"seven_day_routines",
"seven_day_claude_routines",
"claude_routines",
"routines",
"routine",
"seven_day_cowork",
"cowork",
])
self.sevenDayRoutines = routines.window
self.sevenDayRoutinesSourceKey = routines.sourceKey
self.iguanaNecktie = Self.decodeWindow(in: container, keys: ["iguana_necktie"])
self.extraUsage = Self.decodeValue(in: container, keys: ["extra_usage"])
}

private static func decodeWindow(
in container: KeyedDecodingContainer<DynamicCodingKey>,
keys: [String]) -> OAuthUsageWindow?
{
self.decodeValue(in: container, keys: keys)
}

private static func decodeWindowWithSource(
in container: KeyedDecodingContainer<DynamicCodingKey>,
keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?)
{
var firstNullKey: String?
for keyName in keys {
guard let key = DynamicCodingKey(stringValue: keyName) else { continue }
guard container.contains(key) else { continue }
if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) {
return (value, keyName)
}
if firstNullKey == nil {
firstNullKey = keyName
}
}
return (nil, firstNullKey)
}

private static func decodeValue<T: Decodable>(
in container: KeyedDecodingContainer<DynamicCodingKey>,
keys: [String]) -> T?
{
for keyName in keys {
guard let key = DynamicCodingKey(stringValue: keyName) else { continue }
if let value = try? container.decodeIfPresent(T.self, forKey: key) {
return value
}
}
return nil
}
}

private struct DynamicCodingKey: CodingKey {
let stringValue: String
let intValue: Int?

init?(stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}

init?(intValue: Int) {
nil
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy {
primary: usage.primary,
secondary: usage.secondary,
tertiary: usage.opus,
extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows,
providerCost: usage.providerCost,
updatedAt: usage.updatedAt,
identity: identity)
Expand Down
61 changes: 58 additions & 3 deletions Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public struct ClaudeUsageSnapshot: Sendable {
public let primary: RateWindow
public let secondary: RateWindow?
public let opus: RateWindow?
public let extraRateWindows: [NamedRateWindow]
public let providerCost: ProviderCostSnapshot?
public let updatedAt: Date
public let accountEmail: String?
Expand All @@ -21,6 +22,7 @@ public struct ClaudeUsageSnapshot: Sendable {
primary: RateWindow,
secondary: RateWindow?,
opus: RateWindow?,
extraRateWindows: [NamedRateWindow] = [],
providerCost: ProviderCostSnapshot? = nil,
updatedAt: Date,
accountEmail: String?,
Expand All @@ -31,6 +33,7 @@ public struct ClaudeUsageSnapshot: Sendable {
self.primary = primary
self.secondary = secondary
self.opus = opus
self.extraRateWindows = extraRateWindows
self.providerCost = providerCost
self.updatedAt = updatedAt
self.accountEmail = accountEmail
Expand Down Expand Up @@ -841,6 +844,7 @@ extension ClaudeUsageFetcher {
let modelSpecific = makeWindow(
usage.sevenDaySonnet ?? usage.sevenDayOpus,
windowMinutes: 7 * 24 * 60)
let extraRateWindows = Self.oauthExtraRateWindows(from: usage)

let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier)
let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod)
Expand All @@ -849,6 +853,7 @@ extension ClaudeUsageFetcher {
primary: primary,
secondary: weekly,
opus: modelSpecific,
extraRateWindows: extraRateWindows,
providerCost: providerCost,
updatedAt: Date(),
accountEmail: nil,
Expand Down Expand Up @@ -887,6 +892,50 @@ extension ClaudeUsageFetcher {
(used: used / 100.0, limit: limit / 100.0)
}

private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] {
let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [
(
id: "claude-design",
title: "Designs",
window: usage.sevenDayDesign,
sourceKey: usage.sevenDayDesignSourceKey),
(
id: "claude-routines",
title: "Daily Routines",
window: usage.sevenDayRoutines,
sourceKey: usage.sevenDayRoutinesSourceKey),
]
if let designKey = usage.sevenDayDesignSourceKey {
Self.log.debug("Claude OAuth extra usage key matched: design=\(designKey)")
}
if let routinesKey = usage.sevenDayRoutinesSourceKey {
Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)")
}
return definitions.compactMap { definition in
let utilization: Double
let resetDate: Date?
if let window = definition.window, let parsedUtilization = window.utilization {
utilization = parsedUtilization
resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt)
} else if definition.sourceKey != nil {
// Keep product bars visible when the API returns a known key with null payload.
utilization = 0
resetDate = nil
} else {
return nil
}
let resetDescription = resetDate.map(Self.formatResetDate)
return NamedRateWindow(
id: definition.id,
title: definition.title,
window: RateWindow(
usedPercent: utilization,
windowMinutes: Self.weeklyWindowMinutes,
resetsAt: resetDate,
resetDescription: resetDescription))
}
}

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

private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot {
Expand Down Expand Up @@ -927,6 +976,7 @@ extension ClaudeUsageFetcher {
primary: primary,
secondary: secondary,
opus: opus,
extraRateWindows: webData.extraRateWindows,
providerCost: webData.extraUsageCost,
updatedAt: Date(),
accountEmail: webData.accountEmail,
Expand Down Expand Up @@ -986,6 +1036,7 @@ extension ClaudeUsageFetcher {
primary: primary,
secondary: weekly,
opus: opus,
extraRateWindows: [],
providerCost: nil,
updatedAt: Date(),
accountEmail: snap.accountEmail,
Expand All @@ -1009,13 +1060,17 @@ extension ClaudeUsageFetcher {
Self.log.debug(msg)
}
}
// Only merge cost extras; keep identity fields from the primary data source.
if snapshot.providerCost == nil, let extra = webData.extraUsageCost {
// Only merge usage/cost extras; keep identity fields from the primary data source.
let mergedExtraRateWindows = snapshot.extraRateWindows.isEmpty ? webData.extraRateWindows : snapshot
.extraRateWindows
let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost
if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows {
return ClaudeUsageSnapshot(
primary: snapshot.primary,
secondary: snapshot.secondary,
opus: snapshot.opus,
providerCost: extra,
extraRateWindows: mergedExtraRateWindows,
providerCost: mergedProviderCost,
updatedAt: snapshot.updatedAt,
accountEmail: snapshot.accountEmail,
accountOrganization: snapshot.accountOrganization,
Expand Down
Loading