mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
fix(menubar): provider-correct plan tab copy; surface model-scoped weekly limits
The Codex plan tab reused the shared connect, no-credentials and reconnect views whose copy was hardcoded for Claude, so the Codex tab showed Connect Claude subscription. Worse, the no-credentials Try Again button always re-ran the Claude bootstrap. The three views now take title, message and action from the call site. The oauth usage endpoint gained a limits array carrying model-scoped weekly buckets (currently Fable). Parse weekly_scoped entries generically by their display name and show them in the Claude hover popover and the plan tab, with the usual pace projection. Parser now has test coverage using the captured live response shape.
This commit is contained in:
parent
8438409252
commit
61209bd8b5
5 changed files with 189 additions and 24 deletions
|
|
@ -1059,6 +1059,9 @@ final class AppStore {
|
|||
if let pct = usage.sevenDaySonnetPercent {
|
||||
details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt))
|
||||
}
|
||||
for scoped in usage.scopedWeekly {
|
||||
details.append(.init(label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt))
|
||||
}
|
||||
}
|
||||
let plan = subscription?.tier.displayName
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: [])
|
||||
|
|
|
|||
|
|
@ -140,9 +140,8 @@ enum ClaudeSubscriptionService {
|
|||
case 200:
|
||||
clearUsageBlock()
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||
let tier = try ClaudeCredentialStore.subscriptionTier()
|
||||
return mapResponse(decoded, rawTier: tier)
|
||||
return try parseUsage(data, rawTier: tier)
|
||||
} catch {
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
|
|
@ -191,17 +190,26 @@ enum ClaudeSubscriptionService {
|
|||
|
||||
// MARK: - Response mapping
|
||||
|
||||
/// Decodes a usage endpoint response body. Internal so tests can feed the
|
||||
/// captured JSON shape without a network round trip.
|
||||
static func parseUsage(_ data: Data, rawTier: String?) throws -> SubscriptionUsage {
|
||||
let decoded = try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||
return mapResponse(decoded, rawTier: rawTier)
|
||||
}
|
||||
|
||||
private struct UsageResponse: Decodable {
|
||||
let fiveHour: Window?
|
||||
let sevenDay: Window?
|
||||
let sevenDayOpus: Window?
|
||||
let sevenDaySonnet: Window?
|
||||
let limits: [Limit]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fiveHour = "five_hour"
|
||||
case sevenDay = "seven_day"
|
||||
case sevenDayOpus = "seven_day_opus"
|
||||
case sevenDaySonnet = "seven_day_sonnet"
|
||||
case limits
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,8 +222,43 @@ enum ClaudeSubscriptionService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Entry in the `limits` array. Model-scoped weekly buckets (like Fable)
|
||||
/// only appear here, not as named top-level windows.
|
||||
private struct Limit: Decodable {
|
||||
let kind: String?
|
||||
let percent: Double?
|
||||
let resetsAt: String?
|
||||
let scope: Scope?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, percent, scope
|
||||
case resetsAt = "resets_at"
|
||||
}
|
||||
|
||||
struct Scope: Decodable {
|
||||
let model: Model?
|
||||
struct Model: Decodable {
|
||||
let displayName: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case displayName = "display_name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
|
||||
SubscriptionUsage(
|
||||
let scopedWeekly = (r.limits ?? []).compactMap { limit -> SubscriptionUsage.ScopedWindow? in
|
||||
guard limit.kind == "weekly_scoped",
|
||||
let name = limit.scope?.model?.displayName,
|
||||
let percent = limit.percent
|
||||
else { return nil }
|
||||
return SubscriptionUsage.ScopedWindow(
|
||||
label: name,
|
||||
percent: percent,
|
||||
resetsAt: parseDate(limit.resetsAt)
|
||||
)
|
||||
}
|
||||
return SubscriptionUsage(
|
||||
tier: SubscriptionUsage.tier(from: rawTier),
|
||||
rawTier: rawTier,
|
||||
fiveHourPercent: r.fiveHour?.utilization,
|
||||
|
|
@ -226,6 +269,7 @@ enum ClaudeSubscriptionService {
|
|||
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
|
||||
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
|
||||
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
|
||||
scopedWeekly: scopedWeekly,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ struct SubscriptionUsage: Sendable, Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
/// A model-scoped weekly limit from the `limits` array (e.g. the Fable
|
||||
/// bucket). The label is the API's `scope.model.display_name`, so new
|
||||
/// model buckets show up without a client update.
|
||||
struct ScopedWindow: Sendable, Equatable {
|
||||
let label: String
|
||||
let percent: Double
|
||||
let resetsAt: Date?
|
||||
}
|
||||
|
||||
let tier: Tier
|
||||
let rawTier: String?
|
||||
let fiveHourPercent: Double?
|
||||
|
|
@ -31,6 +40,7 @@ struct SubscriptionUsage: Sendable, Equatable {
|
|||
let sevenDayOpusResetsAt: Date?
|
||||
let sevenDaySonnetPercent: Double?
|
||||
let sevenDaySonnetResetsAt: Date?
|
||||
let scopedWeekly: [ScopedWindow]
|
||||
let fetchedAt: Date
|
||||
|
||||
static func tier(from raw: String?) -> Tier {
|
||||
|
|
|
|||
|
|
@ -1704,7 +1704,10 @@ private struct PlanInsight: View {
|
|||
Group {
|
||||
switch store.subscriptionLoadState {
|
||||
case .notBootstrapped, .dormant:
|
||||
PlanConnectView { Task { await store.bootstrapSubscription() } }
|
||||
PlanConnectView(
|
||||
title: "Connect Claude subscription",
|
||||
message: "CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically."
|
||||
) { Task { await store.bootstrapSubscription() } }
|
||||
case .bootstrapping:
|
||||
PlanLoadingView()
|
||||
case .loading:
|
||||
|
|
@ -1714,7 +1717,10 @@ private struct PlanInsight: View {
|
|||
PlanLoadingView()
|
||||
}
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView()
|
||||
PlanNoCredentialsView(
|
||||
title: "No Claude credentials found",
|
||||
message: "Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again."
|
||||
) { Task { await store.bootstrapSubscription() } }
|
||||
case .failed:
|
||||
PlanFailedView(error: store.subscriptionError)
|
||||
case .transientFailure:
|
||||
|
|
@ -1724,7 +1730,11 @@ private struct PlanInsight: View {
|
|||
PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.")
|
||||
}
|
||||
case let .terminalFailure(reason):
|
||||
PlanReconnectView(reason: reason) { Task { await store.bootstrapSubscription() } }
|
||||
PlanReconnectView(
|
||||
title: "Reconnect Claude",
|
||||
reason: reason,
|
||||
fallback: "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect."
|
||||
) { Task { await store.bootstrapSubscription() } }
|
||||
case .loaded:
|
||||
if let usage {
|
||||
loadedBody(usage: usage)
|
||||
|
|
@ -1763,6 +1773,9 @@ private struct PlanInsight: View {
|
|||
if let p = usage.sevenDaySonnetPercent {
|
||||
UtilizationRow(label: "7-day Sonnet", percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"])
|
||||
}
|
||||
ForEach(usage.scopedWeekly, id: \.label) { scoped in
|
||||
UtilizationRow(label: "7-day \(scoped.label)", percent: scoped.percent, resetsAt: scoped.resetsAt, projection: projections["scoped_\(scoped.label)"])
|
||||
}
|
||||
}
|
||||
|
||||
OptimizeSavingsBadge(payload: store.payload)
|
||||
|
|
@ -1774,12 +1787,15 @@ private struct PlanInsight: View {
|
|||
|
||||
private func recomputeProjections(usage: SubscriptionUsage) async {
|
||||
var result: [String: WindowProjection] = [:]
|
||||
let inputs: [(String, Double?, Date?, TimeInterval)] = [
|
||||
var inputs: [(String, Double?, Date?, TimeInterval)] = [
|
||||
("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, Self.fiveHourSeconds),
|
||||
("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt, Self.sevenDaySeconds),
|
||||
("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, Self.sevenDaySeconds),
|
||||
("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, Self.sevenDaySeconds),
|
||||
]
|
||||
for scoped in usage.scopedWeekly {
|
||||
inputs.append(("scoped_\(scoped.label)", scoped.percent, scoped.resetsAt, Self.sevenDaySeconds))
|
||||
}
|
||||
for (key, percent, resetsAt, windowSeconds) in inputs {
|
||||
if let projection = await project(key: key, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) {
|
||||
result[key] = projection
|
||||
|
|
@ -1844,24 +1860,24 @@ private struct PlanLoadingView: View {
|
|||
}
|
||||
|
||||
private struct PlanNoCredentialsView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
let title: String
|
||||
let message: String
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "key.slash")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No Claude credentials found")
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.")
|
||||
Text(.init(message))
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 280)
|
||||
Button("Try Again") {
|
||||
Task { await store.bootstrapSubscription() }
|
||||
}
|
||||
Button("Try Again", action: onRetry)
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.brandAccent)
|
||||
|
|
@ -1904,9 +1920,12 @@ private struct PlanFailedView: View {
|
|||
}
|
||||
|
||||
/// Shown the very first time a user opens the Plan tab. Clicking Connect is the
|
||||
/// only path to triggering the macOS keychain prompt for Claude Code credentials —
|
||||
/// the menubar app does not touch the keychain at startup.
|
||||
/// only path to triggering the provider's credential read (for Claude, the
|
||||
/// macOS keychain prompt) — the menubar app does not touch credentials at
|
||||
/// startup.
|
||||
private struct PlanConnectView: View {
|
||||
let title: String
|
||||
let message: String
|
||||
let onConnect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -1914,10 +1933,10 @@ private struct PlanConnectView: View {
|
|||
Image(systemName: "link.circle")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
Text("Connect Claude subscription")
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.")
|
||||
Text(.init(message))
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
|
@ -1934,10 +1953,12 @@ private struct PlanConnectView: View {
|
|||
|
||||
/// Shown when the refresh token has been invalidated (typically because the user
|
||||
/// re-authenticated on another device). Clicking the button re-runs bootstrap,
|
||||
/// which reads Claude's credentials source again and writes a fresh copy to our
|
||||
/// own keychain item.
|
||||
/// which reads the provider's credentials source again and writes a fresh copy
|
||||
/// to our own keychain item.
|
||||
private struct PlanReconnectView: View {
|
||||
let title: String
|
||||
let reason: String?
|
||||
let fallback: String
|
||||
let onReconnect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -1945,10 +1966,10 @@ private struct PlanReconnectView: View {
|
|||
Image(systemName: "arrow.triangle.2.circlepath.circle")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.red)
|
||||
Text("Reconnect Claude")
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(reason ?? "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.")
|
||||
Text(reason ?? fallback)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
|
@ -1978,7 +1999,10 @@ private struct CodexPlanInsight: View {
|
|||
Group {
|
||||
switch store.codexLoadState {
|
||||
case .notBootstrapped, .dormant:
|
||||
PlanConnectView { Task { await store.bootstrapCodex() } }
|
||||
PlanConnectView(
|
||||
title: "Connect ChatGPT subscription",
|
||||
message: "CodeBurn will read your Codex CLI credentials once. After that, the live quota bar shows next to the Codex tab and updates automatically."
|
||||
) { Task { await store.bootstrapCodex() } }
|
||||
case .bootstrapping:
|
||||
PlanLoadingView()
|
||||
case .loading:
|
||||
|
|
@ -1988,7 +2012,10 @@ private struct CodexPlanInsight: View {
|
|||
PlanLoadingView()
|
||||
}
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView()
|
||||
PlanNoCredentialsView(
|
||||
title: "No Codex credentials found",
|
||||
message: "Sign in with Codex first: run `codex login` in your terminal. Then click Try Again."
|
||||
) { Task { await store.bootstrapCodex() } }
|
||||
case .failed:
|
||||
PlanFailedView(error: store.codexError)
|
||||
case .transientFailure:
|
||||
|
|
@ -1998,7 +2025,11 @@ private struct CodexPlanInsight: View {
|
|||
PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.")
|
||||
}
|
||||
case let .terminalFailure(reason):
|
||||
PlanReconnectView(reason: reason) { Task { await store.bootstrapCodex() } }
|
||||
PlanReconnectView(
|
||||
title: "Reconnect Codex",
|
||||
reason: reason,
|
||||
fallback: "Your ChatGPT session has expired. Run `codex login` in your terminal, then click Reconnect."
|
||||
) { Task { await store.bootstrapCodex() } }
|
||||
case .loaded:
|
||||
if let usage = store.codexUsage {
|
||||
loadedBody(usage: usage)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Claude usage response parsing")
|
||||
struct ClaudeSubscriptionParsingTests {
|
||||
// Shape captured from the live oauth/usage endpoint (2026-07): named
|
||||
// windows plus a `limits` array carrying model-scoped weekly buckets.
|
||||
private let liveShape = """
|
||||
{
|
||||
"five_hour": { "utilization": 13.0, "resets_at": "2026-07-02T22:09:59.599633+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null },
|
||||
"seven_day": { "utilization": 63.0, "resets_at": "2026-07-03T06:59:59.599658+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null },
|
||||
"seven_day_oauth_apps": null,
|
||||
"seven_day_opus": null,
|
||||
"seven_day_sonnet": null,
|
||||
"extra_usage": { "is_enabled": false },
|
||||
"limits": [
|
||||
{ "kind": "session", "group": "session", "percent": 13, "severity": "normal", "resets_at": "2026-07-02T22:09:59.456907+00:00", "scope": null, "is_active": false },
|
||||
{ "kind": "weekly_all", "group": "weekly", "percent": 63, "severity": "normal", "resets_at": "2026-07-03T06:59:59.456926+00:00", "scope": null, "is_active": false },
|
||||
{ "kind": "weekly_scoped", "group": "weekly", "percent": 94, "severity": "critical", "resets_at": "2026-07-03T06:59:59.457220+00:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": true }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("model-scoped weekly bucket surfaces with its display name")
|
||||
func scopedWeeklyParsed() throws {
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x")
|
||||
#expect(usage.scopedWeekly.count == 1)
|
||||
let fable = try #require(usage.scopedWeekly.first)
|
||||
#expect(fable.label == "Fable")
|
||||
#expect(fable.percent == 94)
|
||||
#expect(fable.resetsAt != nil)
|
||||
}
|
||||
|
||||
@Test("named windows still map alongside limits")
|
||||
func namedWindowsUnaffected() throws {
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x")
|
||||
#expect(usage.fiveHourPercent == 13.0)
|
||||
#expect(usage.sevenDayPercent == 63.0)
|
||||
#expect(usage.sevenDayOpusPercent == nil)
|
||||
#expect(usage.sevenDaySonnetPercent == nil)
|
||||
#expect(usage.tier == .max20x)
|
||||
}
|
||||
|
||||
@Test("session and weekly_all limits are not duplicated as scoped rows")
|
||||
func unscopedKindsSkipped() throws {
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: nil)
|
||||
#expect(!usage.scopedWeekly.contains { $0.percent == 13 || $0.percent == 63 })
|
||||
}
|
||||
|
||||
@Test("response without a limits array parses with no scoped windows")
|
||||
func missingLimitsIsBackCompat() throws {
|
||||
let old = """
|
||||
{
|
||||
"five_hour": { "utilization": 40.0, "resets_at": "2026-07-02T22:00:00+00:00" },
|
||||
"seven_day": { "utilization": 12.5, "resets_at": "2026-07-03T07:00:00+00:00" }
|
||||
}
|
||||
"""
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(old.utf8), rawTier: "pro")
|
||||
#expect(usage.scopedWeekly.isEmpty)
|
||||
#expect(usage.fiveHourPercent == 40.0)
|
||||
#expect(usage.tier == .pro)
|
||||
}
|
||||
|
||||
@Test("weekly_scoped without a model display name is skipped")
|
||||
func scopedWithoutNameSkipped() throws {
|
||||
let body = """
|
||||
{
|
||||
"limits": [
|
||||
{ "kind": "weekly_scoped", "percent": 50, "resets_at": "2026-07-03T07:00:00+00:00", "scope": { "model": null } }
|
||||
]
|
||||
}
|
||||
"""
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(body.utf8), rawTier: nil)
|
||||
#expect(usage.scopedWeekly.isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue