From 7b6aa7e725935179c7a35dab8ba876b80a0afc4b Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 3 Oct 2026 03:12:18 +0530 Subject: [PATCH] Show provider-reported Codex credit balances --- DESIGN.md | 8 +++ README.md | 2 +- Sources/ContextCore/LocalUsage.swift | 16 +++++ Sources/ContextCore/ProviderQuotaClient.swift | 19 +++++- Sources/ContextDaddy/UsageSourcePanels.swift | 24 +++++-- .../ProviderQuotaClientTests.swift | 52 ++++++++++++++++ .../UsageAllowanceViewTests.swift | 62 +++++++++++++++++++ 7 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 Tests/ContextDaddyTests/UsageAllowanceViewTests.swift diff --git a/DESIGN.md b/DESIGN.md index 2a311d5..e42e64e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,5 +1,13 @@ # ContextDaddy design contract +Platform: native-macos +Supported minimum width: 960 + +Codex credit evidence reviews the native Provider allowance panel in AppKit-hosted +SwiftUI windows at 960, 1180 and 1440 logical points, captured at the display's +2x backing scale. Credit balance uses the existing secondary-text treatment and +stays separate from reset grants, dollar amounts and local history. + ## Selected direction Owner-directed StorageDaddy family fork: keep the A+C information architecture (live operations plus a policy ledger), but express it through StorageDaddy's warm black-and-mint visual language, rounded typography, fine outlines, and editorial Daddy doodles. diff --git a/README.md b/README.md index 4d2438b..38e574b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The primary navigation is **Usage**, **Skills**, **Projects**, and **OpenTelemet - Usage first scan shows side-by-side Codex/Claude allowance followed by one local history chart including Devin. The historical chart has shared agent filters, model/model-provider/project grouping where supported, range, day/week/month scale, generated/cache/cost metrics where available, selectable periods and exact breakdown. Model, project, and session drill-downs remain below. Cache reads, generated tokens, and estimated cost stay separate; unpriced models are flagged. - Skills can run an explicit, read-only **Read local history** scan. Claude and Devin `skill` tool calls are counted separately from Cursor/Grok skill-file reads and Codex tool calls that reference a `SKILL.md` path. The library shows last-seen dates, agent and folder evidence, 30/90-day or all-history windows, and an evidence-only filter. Cursor dates use transcript modification time because its records lack event timestamps. Name-only tool calls cannot identify a particular same-name copy; no recorded event never means unused. This backfill is held in memory for the app session and does not automatically remove skills. - Project grouping is a separately labelled session ledger, joining ccusage session IDs to Codex's read-only thread-index `cwd`, Grok's project paths, and Claude's encoded project slugs. It queries only Codex rollout path and working directory metadata, never prompt bodies. Buckets use session last activity, not an invented daily allocation; totals may not reconcile to daily accounting. Missing session identity remains **Unattributed**. Model-provider labels are inferred from reported model names, not billing endpoints; unknown aliases remain unknown. Devin's indexed daily tokens participate in the shared model and provider grouping; project attribution remains unavailable. -- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's available usage-reset count is shown when its CLI emits a limit-reset notice; absent counts are explicitly not reported, never assumed to be zero. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy only reads the notices and never invokes `/limit-reset` or consumes a grant. +- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex's reported credit balance appears in credit units, separately from full-reset grants; unlimited, unavailable, and unreported balances remain explicit. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's available usage-reset count is shown when its CLI emits a limit-reset notice; absent counts are explicitly not reported, never assumed to be zero. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy only reads the notices and never invokes `/limit-reset` or consumes a grant. - Skills and OTEL review panels can **Copy all issues** into an agent-ready brief with IDs, evidence, source scope, and verification limits. After skill edits, **Verify after changes** rescans and distinguishes detector-cleared, still-detected, and unverified findings. The library supports previewed local skill edits with recovery; OTEL signals need a new comparable observation window. - Files & diagnostics configuration findings have the same copy-all and rescan handoff without copying configuration values or modifying files. This file audit detects the two misplaced `otel.*` keys but cannot detect launch-time `session-flags.token_budget`; that warning must be traced to the launcher supplying the flag. - Cursor remains inventory-only for usage until a verified source exists. diff --git a/Sources/ContextCore/LocalUsage.swift b/Sources/ContextCore/LocalUsage.swift index 1940860..29630fb 100644 --- a/Sources/ContextCore/LocalUsage.swift +++ b/Sources/ContextCore/LocalUsage.swift @@ -346,10 +346,26 @@ public struct ProviderCreditBalance: Decodable, Sendable { public let remainingPercent: Double? public let usedAmount: Double? public let limitAmount: Double? + /// Codex reports a credit-unit balance, not a dollar amount or reset count. + public let balance: Decimal? + public let hasCredits: Bool? + public let unlimited: Bool? + + public init(remainingPercent: Double? = nil, usedAmount: Double? = nil, limitAmount: Double? = nil, + balance: Decimal? = nil, hasCredits: Bool? = nil, unlimited: Bool? = nil) { + self.remainingPercent = remainingPercent + self.usedAmount = usedAmount + self.limitAmount = limitAmount + self.balance = balance + self.hasCredits = hasCredits + self.unlimited = unlimited + } enum CodingKeys: String, CodingKey { case remainingPercent = "remaining_percent" case usedAmount = "used_amount", limitAmount = "limit_amount" + case balance, unlimited + case hasCredits = "has_credits" } } diff --git a/Sources/ContextCore/ProviderQuotaClient.swift b/Sources/ContextCore/ProviderQuotaClient.swift index 97e2f26..5e2fa2a 100644 --- a/Sources/ContextCore/ProviderQuotaClient.swift +++ b/Sources/ContextCore/ProviderQuotaClient.swift @@ -215,7 +215,8 @@ enum ProviderQuotaParser { resetDescription: nil)) } } - guard !windows.isEmpty else { throw ProviderQuotaError.requestFailed("Codex") } + let credits = selected.first(where: { $0.0 == "codex" }).flatMap { codexCredits($0.1["credits"]) } + guard !windows.isEmpty || credits != nil else { throw ProviderQuotaError.requestFailed("Codex") } windows.sort { ($0.windowDurationMinutes ?? .max, $0.label) < ($1.windowDurationMinutes ?? .max, $1.label) } let resetSummary = result["rateLimitResetCredits"] as? [String: Any] let reset = resetSummary?["availableCount"] as? NSNumber @@ -225,12 +226,26 @@ enum ProviderQuotaParser { let noExpiryCount = availableDetails?.filter { $0["expiresAt"] is NSNull }.count return ProviderQuotaStatus(provider: "codex", status: "ready", source: "codex app-server account/rateLimits/read", checkedAt: timestamp(), plan: plan, windows: windows, - credits: nil, resetCredits: reset?.uint64Value, + credits: credits, resetCredits: reset?.uint64Value, latestReportedResetCreditExpiryUnix: expiries?.max(), resetCreditDetailsCount: availableDetails.map { UInt64($0.count) }, resetCreditsWithoutExpiryCount: noExpiryCount.map(UInt64.init), message: nil) } + private static func codexCredits(_ value: Any?) -> ProviderCreditBalance? { + guard let snapshot = value as? [String: Any] else { return nil } + var balance: Decimal? + if let raw = snapshot["balance"] as? String, + raw.range(of: #"^[0-9]+(?:\.[0-9]+)?$"#, options: .regularExpression) != nil, + let amount = Decimal(string: raw, locale: Locale(identifier: "en_US_POSIX")), !amount.isNaN { + balance = amount + } + let hasCredits = snapshot["hasCredits"] as? Bool + let unlimited = snapshot["unlimited"] as? Bool + guard balance != nil || hasCredits != nil || unlimited != nil else { return nil } + return ProviderCreditBalance(balance: balance, hasCredits: hasCredits, unlimited: unlimited) + } + static func claude(_ output: String) throws -> ProviderQuotaStatus { enum Section { case current, weekly, model(String), credits } var section: Section? diff --git a/Sources/ContextDaddy/UsageSourcePanels.swift b/Sources/ContextDaddy/UsageSourcePanels.swift index 6cfe227..8e44580 100644 --- a/Sources/ContextDaddy/UsageSourcePanels.swift +++ b/Sources/ContextDaddy/UsageSourcePanels.swift @@ -64,12 +64,12 @@ struct UsageAllowanceView: View { .foregroundStyle(DaddyTheme.muted) } Spacer() - Text(status == nil ? "NOT CHECKED" : status?.status == "ready" && !windows.isEmpty - ? health(worst).0.uppercased() : "UNAVAILABLE") + Text(status == nil ? "NOT CHECKED" : status?.status == "ready" + ? (windows.isEmpty ? "READY" : health(worst).0.uppercased()) : "UNAVAILABLE") .font(.caption2.weight(.bold)) .foregroundStyle(status?.status == "ready" && error == nil ? health(worst).1 : DaddyTheme.muted) } - if status?.status == "ready", !windows.isEmpty { + if status?.status == "ready" { ViewThatFits(in: .horizontal) { HStack(alignment: .top, spacing: 14) { ForEach(windows, id: \.id) { window in @@ -80,9 +80,10 @@ struct UsageAllowanceView: View { ForEach(windows, id: \.id) { window in windowValue(window) } } } - if let status, let creditText = creditText(status) { + if let status, let creditText = Self.creditText(status) { Text(creditText).font(.caption2.weight(.semibold)) .foregroundStyle(DaddyTheme.muted) + .fixedSize(horizontal: false, vertical: true) if let resets = status.resetCredits { Text(resetCountText(resets, provider: provider)) .font(.caption2).foregroundStyle(DaddyTheme.muted) @@ -169,7 +170,20 @@ struct UsageAllowanceView: View { return "\(delta > 0 ? "+" : "")\(Int(delta.rounded())) pts \(delta > 0 ? "ahead of" : "behind") even pace" } - private func creditText(_ status: ProviderQuotaStatus) -> String? { + static func creditText(_ status: ProviderQuotaStatus) -> String? { + if status.provider == "codex" { + guard let credits = status.credits else { return "Credit balance not reported" } + if credits.unlimited == true { return "Unlimited credits" } + if let balance = credits.balance { + if balance > 0, balance < Decimal(1) / 100 { + return "<\((Decimal(1) / 100).formatted(.number)) credits remaining" + } + return "\(balance.formatted(.number.precision(.fractionLength(0...2)))) credits remaining" + } + if credits.hasCredits == false { return "No credits available" } + if credits.hasCredits == true { return "Credits available · balance not reported" } + return "Credit balance not reported" + } guard let credits = status.credits else { return nil } if let limit = credits.limitAmount, let used = credits.usedAmount { return "\(max(0, limit - used).formatted(.currency(code: "USD"))) credits" diff --git a/Tests/ContextCoreTests/ProviderQuotaClientTests.swift b/Tests/ContextCoreTests/ProviderQuotaClientTests.swift index eb10209..a5f70c4 100644 --- a/Tests/ContextCoreTests/ProviderQuotaClientTests.swift +++ b/Tests/ContextCoreTests/ProviderQuotaClientTests.swift @@ -3,6 +3,58 @@ import Testing @testable import ContextCore struct ProviderQuotaClientTests { + @Test func codexProjectsCreditUnitsWithoutMixingResetGrantsOrSpark() throws { + let response: [String: Any] = ["result": [ + "rateLimitsByLimitId": [ + "codex": ["primary": ["usedPercent": 30.0], + "credits": ["balance": "12345.6789000", "hasCredits": true, "unlimited": false]], + "spark": ["credits": ["balance": "999999", "hasCredits": true, "unlimited": true]], + ], + "rateLimitResetCredits": ["availableCount": 2], + ]] + let status = try ProviderQuotaParser.codex(response) + #expect(status.credits?.balance == Decimal(string: "12345.6789000")) + #expect(status.credits?.hasCredits == true) + #expect(status.credits?.unlimited == false) + #expect(status.credits?.limitAmount == nil) + #expect(status.credits?.remainingPercent == nil) + #expect(status.resetCredits == 2) + } + + @Test func codexAcceptsCreditOnlyAndLegacySingleBucketReadings() throws { + let creditOnly = try ProviderQuotaParser.codex(["result": ["rateLimits": [ + "credits": ["hasCredits": true, "unlimited": true, "balance": NSNull()], + ]]]) + #expect(creditOnly.windows.isEmpty) + #expect(creditOnly.credits?.unlimited == true) + #expect(creditOnly.credits?.balance == nil) + + let depleted = try ProviderQuotaParser.codex(["result": ["rateLimits": [ + "primary": ["usedPercent": 100.0], + "credits": ["hasCredits": false, "unlimited": false, "balance": "0"], + ]]]) + #expect(depleted.credits?.balance == 0) + #expect(depleted.credits?.hasCredits == false) + #expect(depleted.resetCredits == nil) + } + + @Test func codexMissingAndMalformedBalancesStayUnknown() throws { + for value: Any in [NSNull(), [:] as [String: Any]] { + let status = try ProviderQuotaParser.codex(["result": ["rateLimits": [ + "primary": ["usedPercent": 30.0], "credits": value, + ]]]) + #expect(status.credits == nil) + } + for value: Any in [NSNull(), "NaN", "Infinity", "-1", "12 credits", "", "1,234", 123] { + let status = try ProviderQuotaParser.codex(["result": ["rateLimits": [ + "primary": ["usedPercent": 30.0], + "credits": ["balance": value, "hasCredits": true, "unlimited": false], + ]]]) + #expect(status.credits?.balance == nil) + #expect(status.credits?.hasCredits == true) + } + } + @Test func codexUsesAccountBucketAndExcludesSparkAndIdentity() throws { let response: [String: Any] = [ "id": 1, diff --git a/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift b/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift new file mode 100644 index 0000000..f25d105 --- /dev/null +++ b/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift @@ -0,0 +1,62 @@ +import AppKit +import ContextCore +import SwiftUI +import Testing +@testable import ContextDaddy + +@MainActor +struct UsageAllowanceViewTests { + @Test func creditLabelsDistinguishUnitsAndMissingStates() throws { + func status(_ credits: String, provider: String = "codex") throws -> ProviderQuotaStatus { + let json = "{\"provider\":\"\(provider)\",\"status\":\"ready\",\"source\":\"fixture\",\"checked_at\":\"2026-10-03T12:00:00Z\",\"windows\":[],\"credits\":\(credits)}" + return try JSONDecoder().decode(ProviderQuotaStatus.self, from: Data(json.utf8)) + } + let balance = Decimal(string: "12345.6789")! + let formatted = balance.formatted(.number.precision(.fractionLength(0...2))) + #expect(try UsageAllowanceView.creditText(status(#"{"balance":12345.6789}"#)) == "\(formatted) credits remaining") + #expect(try UsageAllowanceView.creditText(status(#"{"unlimited":true,"balance":0}"#)) == "Unlimited credits") + #expect(try UsageAllowanceView.creditText(status(#"{"balance":0,"has_credits":false}"#)) == "0 credits remaining") + #expect(try UsageAllowanceView.creditText(status(#"{"has_credits":false}"#)) == "No credits available") + #expect(try UsageAllowanceView.creditText(status(#"{"has_credits":true}"#)) == "Credits available · balance not reported") + #expect(try UsageAllowanceView.creditText(status("null")) == "Credit balance not reported") + let tiny = (Decimal(1) / 100).formatted(.number) + #expect(try UsageAllowanceView.creditText(status(#"{"balance":0.001}"#)) == "<\(tiny) credits remaining") + let dollars = 125.formatted(.currency(code: "USD")) + #expect(try UsageAllowanceView.creditText(status(#"{"used_amount":25,"limit_amount":150}"#, provider: "claude")) == "\(dollars) credits") + } + + /// Focused native evidence; no local-history scan or provider call is needed. + @Test func rendersCreditEvidence() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let directory = root.appendingPathComponent("artifacts/design/codex-credits/current", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let model = ContextDaddyModel() + let originalAutoCheck = model.autoCheckAllowance + defer { model.autoCheckAllowance = originalAutoCheck } + model.autoCheckAllowance = false + let fixture = #"{"schema_version":"contextdaddy.provider-quota/v1","generated_at":"2026-10-03T12:00:00Z","providers":[{"provider":"codex","status":"ready","source":"Codex fixture","checked_at":"2026-10-03T12:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"5-hour window","remaining_percent":70}],"credits":{"balance":12345.6789,"has_credits":true,"unlimited":false},"reset_credits":2,"message":null},{"provider":"claude","status":"ready","source":"Claude fixture","checked_at":"2026-10-03T12:00:00Z","windows":[{"id":"current","label":"Current window","remaining_percent":80},{"id":"weekly","label":"Weekly window","remaining_percent":60}],"credits":{"used_amount":25,"limit_amount":150},"reset_credits":1,"message":null}]}"# + let receipt = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(fixture.utf8)) + model.quotaReceipts["codex"] = receipt + model.quotaReceipts["claude"] = receipt + for width in [960, 1180, 1440] { + let view = FocusDeskView().environment(model) + .frame(width: CGFloat(width), height: 740, alignment: .topLeading) + .background(DaddyTheme.canvas).preferredColorScheme(.dark) + .tint(DaddyTheme.mint).buttonStyle(ContextDaddyButtonStyle()) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: width, height: 740) + let window = NSWindow(contentRect: hosting.frame, styleMask: [], backing: .buffered, defer: false) + window.contentView = hosting + hosting.layoutSubtreeIfNeeded() + window.displayIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + hosting.layoutSubtreeIfNeeded() + window.displayIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: directory.appendingPathComponent("allowance-\(width).png"), options: .atomic) + } + } +}