diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f3ad60a..5ddbf14a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ on Columbus Labs QuotaKit releases and product-facing changes. ### Added +- Providers: add Codex personal-access-token usage, OpenCode Go API quotas, Fireworks account discovery, Kiro overage limits, z.ai China balances, and live Grok/xAI spend sources. +- Menu bar: add reusable conditional layout rules, richer conditional metrics, and stable multi-line rendering. +- Claude: retain complete claude-swap usage at exhausted limits and disambiguate same-email accounts with stable aliases. - Personalization: add per-provider accent colors, configurable workday tick contrast, removable widget backgrounds, compact run-out tokens, and token-aware cost history charts. - Usage & Spend: add source-safe project breakdowns, partial-cost reporting, provider-qualified model pricing, and Codex session grouping in the CLI. - Accounts: add bounded Kiro reauthentication, Cursor app-session discovery, explicit Codex external-auth controls, and settings recovery for minimized windows. @@ -25,6 +28,8 @@ on Columbus Labs QuotaKit releases and product-facing changes. ### Fixed +- Usage & Spend: share one provider-source publication between Overview and the dashboard, count every enabled provider, align calendar bucketing, and drain completed Codex catch-up files. +- Mac reliability: preserve cached menu-card subclasses, prevent post-exit RPC pipe aborts, and size agent-session menus from their actual content. - Codex: keep provider-owned OAuth files read-only, recover account identity from JWT claims, preserve established-empty history, and retain known spend when some models cannot be priced. - Claude, Cursor, Grok, Ollama, OpenCode Go, Vertex AI, and Antigravity: improve account recovery, local-source authority, plan labels, cookie parsing, quota estimates, limit matching, and dashboard lane selection without live credential probing. - Mac lifecycle: remove the hidden keepalive window while retaining continuous Mac-to-iPhone sync and noninteractive legacy Keychain migration from durable app startup ownership. diff --git a/CodexBarMobile/CodexBarMobile/Views/KiroCreditsCard.swift b/CodexBarMobile/CodexBarMobile/Views/KiroCreditsCard.swift index 3737a75b0..28ab3cfb4 100644 --- a/CodexBarMobile/CodexBarMobile/Views/KiroCreditsCard.swift +++ b/CodexBarMobile/CodexBarMobile/Views/KiroCreditsCard.swift @@ -1,6 +1,38 @@ import CodexBarSync import SwiftUI +struct KiroOveragePresentation: Equatable { + let creditsUsed: Double? + let creditsCap: Double? + let creditsRemaining: Double? + let creditsFraction: Double? + let charges: Double? + let chargeLimit: Double? + let currencyCode: String + + init?(_ credits: SyncKiroCredits) { + let used = credits.overageCreditsUsed + let cap = credits.overageCreditsCap.flatMap { $0 > 0 ? $0 : nil } + let charges = credits.overageCharges ?? credits.estimatedOverageCostUSD + guard (used ?? 0) > 0 || cap != nil || (charges ?? 0) > 0 else { return nil } + + self.creditsUsed = used + self.creditsCap = cap + self.creditsRemaining = cap.map { max(0, $0 - (used ?? 0)) } + self.creditsFraction = cap.map { min(max((used ?? 0) / $0, 0), 1) } + self.charges = charges + self.chargeLimit = credits.overageChargeLimit + self.currencyCode = credits.overageCharges != nil + ? Self.normalizedCurrencyCode(credits.overageCurrencyCode) + : "USD" + } + + private static func normalizedCurrencyCode(_ code: String?) -> String { + let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() ?? "" + return normalized.count == 3 ? normalized : "USD" + } +} + /// Dedicated Kiro credit-display card. Mirrors the Mac MenuCard /// affordance added in upstream PR #933 — plan tag + primary credits /// progress + optional bonus pool with expiry countdown. @@ -29,9 +61,8 @@ struct KiroCreditsCard: View { self.credits.bonusTotal != nil && (self.credits.bonusTotal ?? 0) > 0 } - private var hasOverage: Bool { - (self.credits.overageCreditsUsed ?? 0) > 0 - || (self.credits.estimatedOverageCostUSD ?? 0) > 0 + private var overagePresentation: KiroOveragePresentation? { + KiroOveragePresentation(self.credits) } var body: some View { @@ -45,9 +76,9 @@ struct KiroCreditsCard: View { self.bonusRow(fraction: bonusFraction) } - if self.hasOverage { + if let overage = self.overagePresentation { Divider() - self.overageRow + self.overageRow(overage) } } .padding(16) @@ -60,34 +91,69 @@ struct KiroCreditsCard: View { /// `overage_credits_used` (Kiro plan exhausted, user paying /// per-credit). Mirrors Mac's v0.27.0 "overage credits / overage /// cost" menu bar display modes. - private var overageRow: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(String(localized: "kiro_overage_label", defaultValue: "Overage")) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - if let usedCredits = credits.overageCreditsUsed, usedCredits > 0 { - Text(String( - format: String(localized: "kiro_overage_credits_format", defaultValue: "+%@ credits"), - Self.formatCredits(usedCredits))) - .font(.caption.bold().monospacedDigit()) - .foregroundStyle(.orange) + private func overageRow(_ overage: KiroOveragePresentation) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(String(localized: "kiro_overage_label", defaultValue: "Overage")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let usedCredits = overage.creditsUsed { + Text(self.overageCreditsText(used: usedCredits, cap: overage.creditsCap)) + .font(.caption.bold().monospacedDigit()) + .foregroundStyle(.orange) + } } - if let costUSD = credits.estimatedOverageCostUSD, costUSD > 0 { - Text(self.overageCostText(costUSD: costUSD)) - .font(.caption.bold().monospacedDigit()) - .foregroundStyle(.orange) + + if let fraction = overage.creditsFraction { + ProgressView(value: fraction) + .progressViewStyle(.linear) + .tint(.orange) + } + + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let remaining = overage.creditsRemaining { + Text(String( + format: String(localized: "%@ credits left"), + Self.formatCredits(remaining))) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + if let charges = overage.charges { + Text(self.overageCostText( + charges: charges, + limit: overage.chargeLimit, + currencyCode: overage.currencyCode)) + .font(.caption.bold().monospacedDigit()) + .foregroundStyle(.orange) + } } } .accessibilityIdentifier("kiro-overage-row") } - private func overageCostText(costUSD: Double) -> String { + private func overageCreditsText(used: Double, cap: Double?) -> String { + guard let cap else { + return String( + format: String(localized: "kiro_overage_credits_format", defaultValue: "+%@ credits"), + Self.formatCredits(used)) + } + return "\(Self.formatCredits(used)) / \(Self.formatCredits(cap))" + } + + private func overageCostText(charges: Double, limit: Double?, currencyCode: String) -> String { + let used = Self.currencyText(charges, currencyCode: currencyCode) + guard let limit else { return used } + return "\(used) / \(Self.currencyText(limit, currencyCode: currencyCode))" + } + + private static func currencyText(_ value: Double, currencyCode: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = "USD" - formatter.maximumFractionDigits = costUSD < 10 ? 2 : 0 - return formatter.string(from: NSNumber(value: costUSD)) ?? "$\(costUSD)" + formatter.currencyCode = currencyCode + formatter.maximumFractionDigits = value < 10 ? 2 : 0 + return formatter.string(from: NSNumber(value: value)) ?? "\(currencyCode) \(value)" } private var header: some View { @@ -218,7 +284,11 @@ struct KiroCreditsCard: View { bonusExpiryDays: 7, resetsAt: nil, overageCreditsUsed: 145, - estimatedOverageCostUSD: 2.45), + estimatedOverageCostUSD: nil, + overageCreditsCap: 500, + overageCharges: 18.85, + overageChargeLimit: 65, + overageCurrencyCode: "EUR"), tintColor: Color(red: 0.25, green: 0.62, blue: 0.49)) .padding() } diff --git a/CodexBarMobile/CodexBarMobileTests/V026ViewSmokeTests.swift b/CodexBarMobile/CodexBarMobileTests/V026ViewSmokeTests.swift index af638c9b1..78a12dd74 100644 --- a/CodexBarMobile/CodexBarMobileTests/V026ViewSmokeTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/V026ViewSmokeTests.swift @@ -128,6 +128,53 @@ final class V026ViewSmokeTests: XCTestCase { XCTAssertNotNil(image) } + func testKiroOveragePresentationUsesCapRemainingAndNativeCurrency() throws { + let credits = SyncKiroCredits( + planName: "Pro", + creditsUsed: 1000, + creditsTotal: 1000, + creditsPercent: 100, + bonusUsed: nil, + bonusTotal: nil, + bonusExpiryDays: nil, + resetsAt: Date(), + overageCreditsUsed: 125, + estimatedOverageCostUSD: nil, + overageCreditsCap: 500, + overageCharges: 18.75, + overageChargeLimit: 75, + overageCurrencyCode: "eur") + + let presentation = try XCTUnwrap(KiroOveragePresentation(credits)) + + XCTAssertEqual(presentation.creditsRemaining, 375) + XCTAssertEqual(presentation.creditsFraction, 0.25) + XCTAssertEqual(presentation.charges, 18.75) + XCTAssertEqual(presentation.chargeLimit, 75) + XCTAssertEqual(presentation.currencyCode, "EUR") + } + + func testKiroOveragePresentationFallsBackToLegacyUSDEstimate() throws { + let credits = SyncKiroCredits( + planName: "Pro", + creditsUsed: 1000, + creditsTotal: 1000, + creditsPercent: 100, + bonusUsed: nil, + bonusTotal: nil, + bonusExpiryDays: nil, + resetsAt: nil, + overageCreditsUsed: 25, + estimatedOverageCostUSD: 2.5) + + let presentation = try XCTUnwrap(KiroOveragePresentation(credits)) + + XCTAssertEqual(presentation.charges, 2.5) + XCTAssertEqual(presentation.currencyCode, "USD") + XCTAssertNil(presentation.creditsCap) + XCTAssertNil(presentation.creditsRemaining) + } + func testAntigravityAccountSwitcherRendersSingleAccount() { // When only one Google account is wired, the switcher should // still render the row (caller already gates count > 1 in the diff --git a/README.md b/README.md index bb972d313..040f110fb 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Provider setup notes and Mac provider internals live in [docs/providers.md](docs - [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available. - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. - [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage. -- [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows. +- [OpenCode Go](docs/opencode.md) — Usage API, browser fallback, and local SQLite cost history. - [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas. - [Alibaba Token Plan](docs/alibaba-token-plan.md) — Bailian browser/manual cookies for token-plan credits. - [Qwen Cloud](docs/qwen-cloud.md) — 5-hour and weekly individual Token Plan usage via browser/manual cookies. diff --git a/Shared/Models/V026Snapshots.swift b/Shared/Models/V026Snapshots.swift index f41a72ce1..4cee00beb 100644 --- a/Shared/Models/V026Snapshots.swift +++ b/Shared/Models/V026Snapshots.swift @@ -175,10 +175,8 @@ public struct SyncZaiHourlyUsage: Codable, Sendable, Equatable { /// Kiro plan + monthly credit allowance + optional bonus pool. /// Populated only on the `kiro` provider snapshot. /// -/// v0.27.0 (upstream) added two overage fields — `overageCreditsUsed` -/// and `estimatedOverageCostUSD` — that Mac surfaces when a plan has -/// been exhausted. Both are optional + decoded with `decodeIfPresent` -/// so pre-v0.27.0 payloads (no overage data) still decode cleanly. +/// Overage fields are optional and decoded with `decodeIfPresent` so older +/// payloads and clients remain compatible as the Mac learns richer limits. public struct SyncKiroCredits: Codable, Sendable, Equatable { public let planName: String? public let creditsUsed: Double @@ -194,8 +192,16 @@ public struct SyncKiroCredits: Codable, Sendable, Equatable { public let overageCreditsUsed: Double? /// Mac-computed `(overageCreditsUsed * priceUSD)` estimate. Always /// USD; nil when no overage data is present or when Kiro has not - /// surfaced a price. iOS displays this as a "overage cost" badge. + /// surfaced a price. Retained for older Mac payloads and iOS clients. public let estimatedOverageCostUSD: Double? + /// Maximum overage credits the account can spend. + public let overageCreditsCap: Double? + /// Actual accrued overage charge in `overageCurrencyCode`. + public let overageCharges: Double? + /// Monetary ceiling corresponding to `overageCreditsCap`. + public let overageChargeLimit: Double? + /// ISO 4217 currency code for `overageCharges` and `overageChargeLimit`. + public let overageCurrencyCode: String? public init( planName: String?, @@ -207,7 +213,11 @@ public struct SyncKiroCredits: Codable, Sendable, Equatable { bonusExpiryDays: Int?, resetsAt: Date?, overageCreditsUsed: Double? = nil, - estimatedOverageCostUSD: Double? = nil) + estimatedOverageCostUSD: Double? = nil, + overageCreditsCap: Double? = nil, + overageCharges: Double? = nil, + overageChargeLimit: Double? = nil, + overageCurrencyCode: String? = nil) { self.planName = planName self.creditsUsed = creditsUsed @@ -219,6 +229,10 @@ public struct SyncKiroCredits: Codable, Sendable, Equatable { self.resetsAt = resetsAt self.overageCreditsUsed = overageCreditsUsed self.estimatedOverageCostUSD = estimatedOverageCostUSD + self.overageCreditsCap = overageCreditsCap + self.overageCharges = overageCharges + self.overageChargeLimit = overageChargeLimit + self.overageCurrencyCode = overageCurrencyCode } public init(from decoder: Decoder) throws { @@ -234,6 +248,10 @@ public struct SyncKiroCredits: Codable, Sendable, Equatable { // v0.27.0 additions — decodeIfPresent so v0.26 payloads decode. self.overageCreditsUsed = try c.decodeIfPresent(Double.self, forKey: .overageCreditsUsed) self.estimatedOverageCostUSD = try c.decodeIfPresent(Double.self, forKey: .estimatedOverageCostUSD) + self.overageCreditsCap = try c.decodeIfPresent(Double.self, forKey: .overageCreditsCap) + self.overageCharges = try c.decodeIfPresent(Double.self, forKey: .overageCharges) + self.overageChargeLimit = try c.decodeIfPresent(Double.self, forKey: .overageChargeLimit) + self.overageCurrencyCode = try c.decodeIfPresent(String.self, forKey: .overageCurrencyCode) } } diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 8edcdd0b6..53463b86d 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -481,6 +481,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.installDebugMemoryPressureObserverIfNeeded() #endif self.ensureStatusController() + self.closeSwiftUISettingsPlaceholderWindow() self.observeSettingsApplicationMenuLanguage() self.scheduleSettingsApplicationMenuValidation( missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount, @@ -523,6 +524,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// The SwiftUI `Settings` scene exists only to own the app-menu Settings command; the real + /// settings window is AppKit-managed (`SettingsWindowController`). macOS can still present or + /// state-restore the scene's empty placeholder window at launch — close it and keep it out of + /// state restoration so it cannot come back on the next launch. + private func closeSwiftUISettingsPlaceholderWindow() { + DispatchQueue.main.async { + for window in NSApp.windows + where window.identifier?.rawValue.hasPrefix("com_apple_SwiftUI_Settings") == true + { + window.isRestorable = false + window.close() + } + } + } + func applicationWillTerminate(_ notification: Notification) { self.cloudSyncCoordinator?.stop() self.memoryPressureMonitor.stop() diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index d9fc9a335..7266c737e 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -213,6 +213,24 @@ func L(_ key: String, language: String) -> String { return codexBarLocalizedString(key, bundle: bundle, resourceBundle: resourceBundle) } +/// Uses an explicit duration for Simplified Chinese quota surfaces while preserving the generic +/// `Session` translation for conversations and other non-quota UI. +func localizedSessionQuotaLabel(_ label: String, windowMinutes: Int?) -> String { + let localizedLabel = L(label) + guard label == "Session", + localizedBundle().bundleURL.lastPathComponent.caseInsensitiveCompare("zh-Hans.lproj") == .orderedSame, + let windowMinutes + else { return localizedLabel } + + if windowMinutes == 7 * 24 * 60 { + return L("Weekly") + } + guard (60...(12 * 60)).contains(windowMinutes), windowMinutes.isMultiple(of: 60) else { + return localizedLabel + } + return "\(codexBarLocalizedInteger(windowMinutes / 60)) \(L("Hour"))" +} + func codexBarLocalizedLocale() -> Locale { codexBarLocale(forLanguage: resolvedAppLanguage()) } diff --git a/Sources/CodexBar/MenuBarLayout.swift b/Sources/CodexBar/MenuBarLayout.swift index 089c9757e..181771b5f 100644 --- a/Sources/CodexBar/MenuBarLayout.swift +++ b/Sources/CodexBar/MenuBarLayout.swift @@ -8,6 +8,149 @@ enum PercentWindow: String, CaseIterable, Codable, Hashable, Sendable { case automatic } +/// Comparison unit of a conditional metric: drives the threshold range, the stepper increment, and the +/// unit label shown next to the threshold field. +enum MenuBarConditionalMetricKind: Sendable { + case percent + case signedPercent + case hours + case currencyUSD +} + +/// What a conditional predicate measures. Persistence keys off the case names, so the first four keep +/// their original spelling: a library written before the metric set grew still decodes unchanged. +/// Declaration order is the editor picker's order: percentages, direct lanes, time to reset, pace, +/// run-out, money. +enum MenuBarConditionalMetric: String, CaseIterable, Codable, Hashable, Sendable { + case session + case weekly + case scopedWeekly + case automatic + case primaryLane + case secondaryLane + case tertiaryLane + case sessionResetsIn + case weeklyResetsIn + case scopedWeeklyResetsIn + case automaticResetsIn + case sessionPace + case weeklyPace + case automaticPace + case runsOutIn + case balance + case costToday + case cost30d + + var kind: MenuBarConditionalMetricKind { + switch self { + case .session, .weekly, .scopedWeekly, .automatic, + .primaryLane, .secondaryLane, .tertiaryLane: + .percent + case .sessionPace, .weeklyPace, .automaticPace: + .signedPercent + case .sessionResetsIn, .weeklyResetsIn, .scopedWeeklyResetsIn, .automaticResetsIn, .runsOutIn: + .hours + case .balance, .costToday, .cost30d: + .currencyUSD + } + } + + /// Whether a used/remaining select applies. Percent windows and lanes expose both readings of the + /// same window; balance exposes spend against remaining credit. Pace is already signed, and a reset + /// countdown or a cost total has no complement. + var supportsDirection: Bool { + switch self { + case .session, .weekly, .scopedWeekly, .automatic, + .primaryLane, .secondaryLane, .tertiaryLane, .balance: + true + default: + false + } + } + + /// Whether the metric is read straight off a `RateWindow`, so refresh gates know to sign that + /// window's raw values. Pace, run-out and money metrics come from upstream-resolved numbers instead. + var readsRateWindow: Bool { + switch self { + case .session, .weekly, .scopedWeekly, .automatic, + .primaryLane, .secondaryLane, .tertiaryLane, + .sessionResetsIn, .weeklyResetsIn, .scopedWeeklyResetsIn, .automaticResetsIn: + true + default: + false + } + } + + /// Whether the metric's value moves with the clock rather than only with new provider data, so + /// refresh scheduling must tick it. Pace compares actual use against elapsed time, and the run-out + /// estimate counts down; reset countdowns are handled by their own exact wake-up instead. + var isClockDerivedRate: Bool { + switch self { + case .sessionPace, .weeklyPace, .automaticPace, .runsOutIn: true + default: false + } + } + + /// Whether the legacy conditional decoder has a case for this metric at all. That schema shipped the + /// conditional editor with only the four percent windows, and its synthesized `Codable` throws on + /// any other raw value — which would take the whole persisted library down with it. The legacy + /// projection written alongside the current library drops entries this returns `false` for. + var hasLegacyRepresentation: Bool { + switch self { + case .session, .weekly, .scopedWeekly, .automatic: true + default: false + } + } + + var thresholdRange: ClosedRange { + switch self.kind { + case .percent: 0...100 + case .signedPercent: -100...100 + // One year, so no realistic reset or run-out window is clamped. + case .hours: 0...8760 + case .currencyUSD: 0...1_000_000 + } + } + + var thresholdStep: Double { + self.kind == .hours ? 0.5 : 1 + } + + /// Unit shown beside the threshold field and appended in the conditional summary. + var thresholdUnit: String { + switch self.kind { + case .percent, .signedPercent: "%" + case .hours: "h" + case .currencyUSD: "USD" + } + } +} + +enum MenuBarConditionalComparison: String, CaseIterable, Codable, Hashable, Sendable { + case greaterThan + case greaterThanOrEqual + case lessThan + case lessThanOrEqual + + var symbol: String { + switch self { + case .greaterThan: ">" + case .greaterThanOrEqual: ">=" + case .lessThan: "<" + case .lessThanOrEqual: "<=" + } + } + + func evaluate(_ value: Double, _ threshold: Double) -> Bool { + switch self { + case .greaterThan: value > threshold + case .greaterThanOrEqual: value >= threshold + case .lessThan: value < threshold + case .lessThanOrEqual: value <= threshold + } + } +} + enum MenuBarLayoutLane: String, CaseIterable, Codable, Hashable, Sendable { case primary case secondary @@ -31,6 +174,248 @@ enum MenuBarLayoutLane: String, CaseIterable, Codable, Hashable, Sendable { } } +enum MenuBarConditionalCombinator: String, CaseIterable, Codable, Hashable, Sendable { + case and + case or +} + +/// Which reading of a metric a predicate compares. +enum MenuBarConditionalDirection: String, CaseIterable, Codable, Hashable, Sendable { + case used + case remaining +} + +struct MenuBarConditionalPredicate: Codable, Hashable, Sendable { + var metric: MenuBarConditionalMetric + /// Which reading of `metric` to compare. Normalized back to `.used` when the metric has no + /// complement, so a stored direction can never contradict the metric. + var direction: MenuBarConditionalDirection + var comparison: MenuBarConditionalComparison + var threshold: Double + + init( + metric: MenuBarConditionalMetric, + direction: MenuBarConditionalDirection = .used, + comparison: MenuBarConditionalComparison, + threshold: Double) + { + self.metric = metric + self.direction = direction + self.comparison = comparison + self.threshold = threshold + } + + private enum CodingKeys: String, CodingKey { + case metric, direction, comparison, threshold + } + + /// Predicates persisted before `direction` existed compared used percentages, so a missing key + /// decodes as `.used` and keeps its original meaning. The synthesized decoder would instead reject + /// the whole predicate. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.metric = try container.decode(MenuBarConditionalMetric.self, forKey: .metric) + self.direction = try container.decodeIfPresent(MenuBarConditionalDirection.self, forKey: .direction) + ?? .used + self.comparison = try container.decode(MenuBarConditionalComparison.self, forKey: .comparison) + self.threshold = try container.decode(Double.self, forKey: .threshold) + } + + /// Clamps the threshold into the metric's unit range and drops a direction the metric cannot use. + func normalized() -> Self { + var copy = self + copy.threshold = self.threshold.clamped(to: self.metric.thresholdRange) + if !self.metric.supportsDirection { + copy.direction = .used + } + return copy + } +} + +struct MenuBarConditionalClause: Codable, Hashable, Sendable { + /// nil for the first clause; ignored-on-eval if set on the first. + var combinator: MenuBarConditionalCombinator? + var predicate: MenuBarConditionalPredicate +} + +struct MenuBarLayoutConditional: Codable, Hashable, Sendable { + let id: UUID + var name: String + var clauses: [MenuBarConditionalClause] // 1...4 after normalization + var thenToken: MenuBarLayoutToken + var elseToken: MenuBarLayoutToken + + init( + id: UUID = UUID(), + name: String = "", + clauses: [MenuBarConditionalClause], + thenToken: MenuBarLayoutToken, + elseToken: MenuBarLayoutToken) + { + self.id = id + self.name = name + self.clauses = clauses + self.thenToken = thenToken + self.elseToken = elseToken + self.normalize() + } + + private mutating func normalize() { + var normalized = self.clauses.prefix(4).map { clause in + var clause = clause + clause.predicate = clause.predicate.normalized() + return clause + } + if normalized.isEmpty { + normalized = [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))] + } + normalized[0].combinator = nil + self.clauses = Array(normalized) + } + + /// Custom Codable so older persisted conditionals without `name` or `id` still decode. + private enum CodingKeys: String, CodingKey { + case id, name, clauses, thenToken, elseToken + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.clauses = try container.decode([MenuBarConditionalClause].self, forKey: .clauses) + self.thenToken = try container.decode(MenuBarLayoutToken.self, forKey: .thenToken) + self.elseToken = try container.decode(MenuBarLayoutToken.self, forKey: .elseToken) + self.normalize() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.name, forKey: .name) + try container.encode(self.clauses, forKey: .clauses) + try container.encode(self.thenToken, forKey: .thenToken) + try container.encode(self.elseToken, forKey: .elseToken) + } + + static func makeDefault() -> MenuBarLayoutConditional { + MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))], + thenToken: .percent(window: .session), + elseToken: .hidden) + } + + /// Conditionals seeded into the library on a fresh install so the palette ships with useful, + /// editable starting points instead of an empty list. + /// + /// Identities are fixed rather than generated: a layout that places a shipped conditional keeps + /// resolving across launches, and once the user edits or clears the library the stored array wins, + /// so a deleted entry is never reseeded. + /// + /// Percent thresholds compare the window's **used** percentage and countdown thresholds compare hours + /// until the window resets, both matching `evaluatesTrue`. + static func shippedLibrary() -> [MenuBarLayoutConditional] { + [ + MenuBarLayoutConditional( + id: self.fixedID("B715B1D1-8C1D-4E99-8050-2B5A4EF4B684"), + name: L("menu_bar_layout_conditional_default_session_busy"), + clauses: [self.clause(.session, .greaterThan, 50)], + thenToken: .percent(window: .session), + elseToken: .hidden), + MenuBarLayoutConditional( + id: self.fixedID("A1C3C131-1BB8-4248-A482-FAF3E403E6E1"), + name: L("menu_bar_layout_conditional_default_weekly_high"), + clauses: [self.clause(.weekly, .greaterThanOrEqual, 90)], + thenToken: .percent(window: .weekly), + elseToken: .hidden), + MenuBarLayoutConditional( + id: self.fixedID("DBF99E1D-D5ED-4D55-AD24-A4171479DA3A"), + name: L("menu_bar_layout_conditional_default_session_spent"), + clauses: [self.clause(.session, .greaterThanOrEqual, 95)], + thenToken: .resetCountdown, + elseToken: .percent(window: .session)), + MenuBarLayoutConditional( + id: self.fixedID("CB1EBADE-B813-4B70-A32D-0FC742DC97A6"), + name: L("menu_bar_layout_conditional_default_either_high"), + clauses: [ + self.clause(.session, .greaterThan, 80), + self.clause(.weekly, .greaterThan, 80, combinator: .or), + ], + thenToken: .resetCountdown, + elseToken: .hidden), + MenuBarLayoutConditional( + id: self.fixedID("4A4E53F8-CABC-4413-B7AA-6C6452A69FEC"), + name: L("menu_bar_layout_conditional_default_scoped_weekly"), + clauses: [self.clause(.scopedWeekly, .greaterThan, 60)], + thenToken: .percent(window: .scopedWeekly), + elseToken: .hidden), + MenuBarLayoutConditional( + id: self.fixedID("98257E78-8E87-4BE4-A917-73F98310143C"), + // Composed from the two palette token labels it switches between, so the chip always + // reads in the same words as the tokens themselves in every language. + name: "\(L("menu_bar_layout_token_auto")) / \(L("menu_bar_layout_token_resets_in"))", + clauses: [self.clause(.automatic, .greaterThanOrEqual, 1, direction: .remaining)], + thenToken: .percent(window: .automatic), + elseToken: .resetCountdown), + ] + } + + /// This conditional as the legacy schema can read it, or nil when it cannot be represented. + /// + /// Two things make an entry unreadable there. A metric outside the original four throws on decode + /// and takes the whole array with it. A non-`.used` direction is worse than unreadable: the extra + /// key is silently ignored by that release's synthesized decoder, so `session remaining > 80` would + /// come back as `session used > 80` and render the opposite branch. Dropping the entry is the honest + /// projection in both cases — a missing rule is visibly missing, an inverted one is not. + var legacyCompatible: MenuBarLayoutConditional? { + let readable = self.clauses.allSatisfy { clause in + clause.predicate.metric.hasLegacyRepresentation && clause.predicate.direction == .used + } + return readable ? self : nil + } + + private static func clause( + _ metric: MenuBarConditionalMetric, + _ comparison: MenuBarConditionalComparison, + _ threshold: Double, + direction: MenuBarConditionalDirection = .used, + combinator: MenuBarConditionalCombinator? = nil) + -> MenuBarConditionalClause + { + MenuBarConditionalClause( + combinator: combinator, + predicate: MenuBarConditionalPredicate( + metric: metric, + direction: direction, + comparison: comparison, + threshold: threshold)) + } + + /// The shipped identities are compile-time constants, so a malformed one is a programmer error + /// rather than something to paper over with a fresh identity that would dangle placed references. + private static func fixedID(_ string: String) -> UUID { + guard let id = UUID(uuidString: string) else { + preconditionFailure("Shipped conditional identity must be a valid UUID: \(string)") + } + return id + } +} + +/// Array element wrapper that tolerates one undecodable conditional instead of failing the whole +/// library. A conditional using a metric this build does not recognize — a downgrade reading a library +/// written by a newer release — must not take every other entry down with it; layouts referencing a +/// dropped entry already render the dangling-conditional placeholder. +struct LenientMenuBarLayoutConditional: Decodable { + let value: MenuBarLayoutConditional? + + init(from decoder: Decoder) throws { + self.value = try? MenuBarLayoutConditional(from: decoder) + } +} + struct MenuBarLayoutLaneLabels: Hashable { let primary: String let secondary: String @@ -74,12 +459,28 @@ enum MenuBarLayoutToken: Codable, Hashable, Sendable { case cost30d case separatorDot case space + /// Renders nothing; used as a conditional branch value to hide output for the other case. + case hidden + /// References a library conditional by UUID. The conditional's content (clauses, branches) lives in + /// the conditionals library; the layout stores only its identity. + case conditional(id: UUID) var selectedLane: MenuBarLayoutLane? { if case let .lanePercent(lane) = self { return lane } return nil } + /// Tokens added after 0.53.x that an older decoder has no case for at all. `legacyCompatible` + /// cannot map them onto an existing case without inventing content, so the layout projection + /// drops them instead: an older release then decodes the rest of the layout rather than + /// failing the whole blob and losing the user's arrangement. + var hasLegacyRepresentation: Bool { + switch self { + case .conditional, .hidden: false + default: true + } + } + /// Maps `lanePercent` onto tokens a 0.53.x decoder already understands so a downgrade keeps a /// layout instead of dropping the whole blob. Direct lanes follow the provider's semantic /// windows: Kimi's primary is weekly, so a Kimi override does not swap 7-day and 5-hour. @@ -132,6 +533,27 @@ enum MenuBarLayoutBalanceResolver { guard provider == .openrouter else { return nil } return snapshot?.detailRow(label: "Remaining")?.value } + + /// Numeric USD amounts behind OpenRouter's "Credits" detail rows. The plugin formats both rows as + /// `$` + `toFixed(2)` (`Sources/CodexBarCore/Resources/Plugins/openrouter.js`), so the amounts are + /// USD with no grouping separators; the plugin never populates `providerCost`, so there is nothing + /// structured to read instead. + static func balanceAmountsUSD( + provider: UsageProvider, + snapshot: UsageSnapshot?) + -> (remaining: Double?, used: Double?) + { + // Provider-specific by design: only OpenRouter reports credit amounts in its "Credits" detail rows. + guard provider == .openrouter else { return (nil, nil) } + return ( + self.amount(snapshot?.detailRow(label: "Remaining")?.value), + self.amount(snapshot?.detailRow(label: "Used")?.value)) + } + + private static func amount(_ text: String?) -> Double? { + guard let text else { return nil } + return Double(text.filter { $0.isNumber || $0 == "." || $0 == "-" }) + } } enum MenuBarLayoutCostResolver { @@ -183,10 +605,21 @@ struct MenuBarLayout: Codable, Hashable, Sendable { Set(self.lines.joined().compactMap(\.selectedLane)) } + /// Older-readable projection of this layout. Tokens an older decoder cannot represent are + /// dropped rather than mapped; a line left empty by that filtering is dropped too, and a layout + /// with nothing left falls back to `defaultLayout` via `MenuBarLayout(lines:)` normalization. func legacyCompatible(for provider: UsageProvider? = nil) -> MenuBarLayout { - MenuBarLayout(lines: self.lines.map { line in - line.map { $0.legacyCompatible(for: provider) } - }) + let projected = self.lines.map { line in + line + .filter(\.hasLegacyRepresentation) + .map { $0.legacyCompatible(for: provider) } + } + // Keep a trailing empty line only when the layout was already stacked with an empty line, + // so an older release does not inherit a blank stacked row created purely by filtering. + let compacted = projected.enumerated().filter { index, line in + !line.isEmpty || self.lines[index].isEmpty + }.map(\.element) + return MenuBarLayout(lines: compacted) } } @@ -195,6 +628,8 @@ enum MenuBarLayoutUserDefaultsKey { static let layoutCurrent = "menuBarLayoutV2" static let overrides = "menuBarLayoutOverrides" static let overridesCurrent = "menuBarLayoutOverridesV2" + static let conditionals = "menuBarLayoutConditionals" + static let conditionalsCurrent = "menuBarLayoutConditionalsV2" } enum MenuBarLayoutPreset: String, CaseIterable, Identifiable, Sendable { @@ -484,4 +919,132 @@ enum MenuBarLayoutPersistence { } return preferred } + + /// Library projection an older conditional-capable release can read, dropping entries it would + /// misread or choke on. + static func legacyCompatibleLibrary( + _ conditionals: [MenuBarLayoutConditional]) + -> [MenuBarLayoutConditional] + { + conditionals.compactMap(\.legacyCompatible) + } + + /// Mirrors `preferredLayout`: the full-fidelity key wins unless the legacy key disagrees with its + /// own projection, which only happens when an older release wrote it, and that edit must survive. + static func preferredLibrary( + current: [MenuBarLayoutConditional]?, + legacy: [MenuBarLayoutConditional]?) + -> [MenuBarLayoutConditional]? + { + if let current { + if let legacy, self.legacyCompatibleLibrary(current) != legacy { + return legacy + } + return current + } + return legacy + } + + static func needsStartupDualWrite( + current: [MenuBarLayoutConditional]?, + legacy: [MenuBarLayoutConditional]?) + -> Bool + { + switch (current, legacy) { + case (nil, .some), (.some, nil): true + default: false + } + } + + static func encodedLibrary( + _ conditionals: [MenuBarLayoutConditional]) + throws -> (current: Data, legacy: Data) + { + let encoder = JSONEncoder() + return try ( + encoder.encode(conditionals), + encoder.encode(self.legacyCompatibleLibrary(conditionals))) + } + + /// Pre-V2 installs only have the legacy key, so materialize both at load: an immediate downgrade + /// then reads a projection that was never written by an older release rather than nothing. + static func loadLibrary( + current: [MenuBarLayoutConditional]?, + legacy: [MenuBarLayoutConditional]?, + into userDefaults: UserDefaults) + -> [MenuBarLayoutConditional]? + { + let preferred = self.preferredLibrary(current: current, legacy: legacy) + if let preferred, + self.needsStartupDualWrite(current: current, legacy: legacy), + let blobs = try? self.encodedLibrary(preferred) + { + userDefaults.set(blobs.current, forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent) + userDefaults.set(blobs.legacy, forKey: MenuBarLayoutUserDefaultsKey.conditionals) + } + return preferred + } +} + +extension MenuBarLayout { + /// Every token in the layout plus all tokens reachable through conditional branches (depth-capped). + func flattenedTokens(conditionals: [MenuBarLayoutConditional]) -> [MenuBarLayoutToken] { + let byID = Dictionary(conditionals.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var tokens: [MenuBarLayoutToken] = [] + for token in self.lines.joined() { + token.appendFlattened(into: &tokens, conditionals: byID, depth: 0) + } + return tokens + } + + /// Every predicate reachable from the conditionals this layout places, including nested branches + /// (depth-capped by `flattenedTokens`). Data-dependency gates use this to see what the conditionals + /// read: a predicate on cost or time to reset has no matching display token to detect. + func referencedConditionalPredicates( + conditionals: [MenuBarLayoutConditional]) + -> [MenuBarConditionalPredicate] + { + let byID = Dictionary(conditionals.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return self.flattenedTokens(conditionals: conditionals) + .flatMap { token -> [MenuBarConditionalPredicate] in + guard case let .conditional(id) = token, let conditional = byID[id] else { return [] } + return conditional.clauses.map(\.predicate) + } + } + + /// Returns a layout with every `.conditional(id:)` token matching `id` removed from both lines, + /// or nil when nothing referenced it (so callers never materialize an unchanged stored layout). + func removingConditional(id: UUID) -> MenuBarLayout? { + var changed = false + let filtered = self.lines.map { line in + line.filter { token in + if case let .conditional(tokenID) = token, tokenID == id { + changed = true + return false + } + return true + } + } + guard changed else { return nil } + return MenuBarLayout(lines: filtered) + } +} + +extension MenuBarLayoutToken { + static let maxConditionalDepth = 8 + + func appendFlattened( + into tokens: inout [MenuBarLayoutToken], + conditionals: [UUID: MenuBarLayoutConditional], + depth: Int) + { + if self == .hidden { return } + tokens.append(self) + guard depth < Self.maxConditionalDepth, + case let .conditional(id) = self, + let conditional = conditionals[id] + else { return } + conditional.thenToken.appendFlattened(into: &tokens, conditionals: conditionals, depth: depth + 1) + conditional.elseToken.appendFlattened(into: &tokens, conditionals: conditionals, depth: depth + 1) + } } diff --git a/Sources/CodexBar/MenuBarLayoutConditionalEditor.swift b/Sources/CodexBar/MenuBarLayoutConditionalEditor.swift new file mode 100644 index 000000000..f3d8c0b8c --- /dev/null +++ b/Sources/CodexBar/MenuBarLayoutConditionalEditor.swift @@ -0,0 +1,410 @@ +import CodexBarCore +import SwiftUI + +struct MenuBarLayoutConditionalDraft: Identifiable { + enum Mode: Hashable { + case create + case edit(UUID) + } + + /// Sheet-presentation identity only, unrelated to `conditional.id`. + let id: UUID + let mode: Mode + var conditional: MenuBarLayoutConditional + + init(mode: Mode, conditional: MenuBarLayoutConditional) { + self.id = UUID() + self.mode = mode + self.conditional = conditional + } +} + +@MainActor +struct MenuBarLayoutConditionalEditorSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var conditional: MenuBarLayoutConditional + + let draft: MenuBarLayoutConditionalDraft + let provider: UsageProvider? + let existingNames: Set + let onSave: (MenuBarLayoutConditionalDraft) -> Void + + init( + draft: MenuBarLayoutConditionalDraft, + provider: UsageProvider?, + existingNames: Set, + onSave: @escaping (MenuBarLayoutConditionalDraft) -> Void) + { + self.draft = draft + self.provider = provider + self.existingNames = existingNames + self.onSave = onSave + self._conditional = State(initialValue: draft.conditional) + } + + private var trimmedName: String { + self.conditional.name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var reservedNames: Set { + var names = self.existingNames + // The entry's own current name is allowed so an edit can be saved unchanged. + names.remove(self.draft.conditional.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + return names + } + + private var nameIsValid: Bool { + !self.trimmedName.isEmpty && !self.reservedNames.contains(self.trimmedName.lowercased()) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L("menu_bar_layout_conditional_name")) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + TextField(L("menu_bar_layout_conditional_name_placeholder"), text: self.$conditional.name) + .textFieldStyle(.roundedBorder) + if !self.nameIsValid { + Text(L("menu_bar_layout_conditional_name_error")) + .font(.caption) + .foregroundStyle(.red) + } + } + + Text(L("menu_bar_layout_conditional_if")) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + ForEach(self.conditional.clauses.indices, id: \.self) { index in + self.clauseRow(index: index) + } + + Button(L("menu_bar_layout_conditional_add_condition")) { + self.conditional.clauses.append( + MenuBarConditionalClause( + combinator: .and, + predicate: MenuBarConditionalPredicate( + metric: .automatic, + comparison: .greaterThan, + threshold: 0))) + } + .disabled(self.conditional.clauses.count >= 4) + .buttonStyle(.link) + + HStack { + Text(L("menu_bar_layout_conditional_then")) + self.tokenMenu(selection: self.thenBinding) + } + HStack { + Text(L("menu_bar_layout_conditional_else")) + self.tokenMenu(selection: self.elseBinding) + } + + Text(self.conditional.editorSummary(provider: self.provider)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + Divider() + + HStack { + Spacer() + Button(L("Cancel"), role: .cancel) { + self.dismiss() + } + Button(L("menu_bar_layout_conditional_save")) { + self.onSave( + MenuBarLayoutConditionalDraft( + mode: self.draft.mode, + conditional: self.conditional)) + self.dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!self.nameIsValid) + } + } + .padding(16) + // Wide enough for combinator + metric + direction + comparison + value + stepper + unit + remove. + .frame(width: 620) + } + + @ViewBuilder + private func clauseRow(index: Int) -> some View { + let metric = self.conditional.clauses.indices.contains(index) + ? self.conditional.clauses[index].predicate.metric + : MenuBarConditionalMetric.session + HStack(spacing: 6) { + if index > 0 { + Picker("", selection: self.combinatorBinding(index)) { + Text(L("menu_bar_layout_conditional_and")).tag(MenuBarConditionalCombinator.and) + Text(L("menu_bar_layout_conditional_or")).tag(MenuBarConditionalCombinator.or) + } + .labelsHidden() + .fixedSize() + } + + Picker("", selection: self.metricBinding(index)) { + ForEach(MenuBarConditionalMetric.allCases, id: \.self) { metric in + Text(metric.editorLabel(provider: self.provider)).tag(metric) + } + } + .labelsHidden() + + if metric.supportsDirection { + Picker("", selection: self.directionBinding(index)) { + Text(L("menu_bar_layout_conditional_used")).tag(MenuBarConditionalDirection.used) + Text(L("menu_bar_layout_conditional_remaining")).tag(MenuBarConditionalDirection.remaining) + } + .labelsHidden() + .fixedSize() + } + + Picker("", selection: self.comparisonBinding(index)) { + ForEach(MenuBarConditionalComparison.allCases, id: \.self) { comparison in + Text(comparison.symbol).tag(comparison) + } + } + .labelsHidden() + + TextField("", value: self.thresholdBinding(index), format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 52) + .monospacedDigit() + Stepper( + value: self.thresholdBinding(index), + in: metric.thresholdRange, + step: metric.thresholdStep) + { + EmptyView() + } + .labelsHidden() + Text(metric.thresholdUnit) + + if self.conditional.clauses.count > 1 { + Button { + self.conditional.clauses.remove(at: index) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.plain) + } + } + } + + private func combinatorBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return .and } + return self.conditional.clauses[index].combinator ?? .and + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].combinator = $0 + }) + } + + private func metricBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return .session } + return self.conditional.clauses[index].predicate.metric + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].predicate.metric = $0 + // Re-normalize so switching metric families cannot leave a threshold outside the new + // unit's range or a direction the new metric has no reading for. + self.conditional.clauses[index].predicate = + self.conditional.clauses[index].predicate.normalized() + }) + } + + private func directionBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return .used } + return self.conditional.clauses[index].predicate.direction + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].predicate.direction = $0 + }) + } + + private func comparisonBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return .greaterThan } + return self.conditional.clauses[index].predicate.comparison + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].predicate.comparison = $0 + }) + } + + private func thresholdBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return 0 } + return self.conditional.clauses[index].predicate.threshold + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + let metric = self.conditional.clauses[index].predicate.metric + self.conditional.clauses[index].predicate.threshold = $0.clamped(to: metric.thresholdRange) + }) + } + + private var thenBinding: Binding { + Binding( + get: { self.conditional.thenToken }, + set: { self.conditional.thenToken = $0 }) + } + + private var elseBinding: Binding { + Binding( + get: { self.conditional.elseToken }, + set: { self.conditional.elseToken = $0 }) + } + + private func tokenMenu(selection: Binding) -> some View { + Menu { + ForEach(Self.selectableTokens, id: \.self) { token in + Button { + selection.wrappedValue = token + } label: { + Label( + token.editorLabel(provider: self.provider), + systemImage: token.editorSystemImage) + } + } + } label: { + MenuBarLayoutChipLabel( + title: selection.wrappedValue.editorLabel(provider: self.provider), + systemImage: selection.wrappedValue.editorSystemImage, + isSelected: false) + } + } + + private static let selectableTokens: [MenuBarLayoutToken] = [ + .icon, + .providerName, + .accountLabel, + .percent(window: .session), + .percent(window: .weekly), + .percent(window: .scopedWeekly), + .percent(window: .automatic), + .usageBar, + .pace(window: .session), + .pace(window: .weekly), + .pace(window: .automatic), + .resetCountdown, + .resetAbsolute, + .runsOut, + .runsOutCompact, + .balance, + .costToday, + .cost30d, + .separatorDot, + .space, + .hidden, + ] +} + +extension MenuBarLayoutConditional { + /// The chip label: the required name, falling back to a generic label if somehow empty. + var displayName: String { + let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? L("menu_bar_layout_token_conditional") : trimmed + } + + /// Produces a summary string reflecting the left-fold AND/OR evaluation order. Mixed + /// combinators parenthesize their accumulator so the reading matches `evaluatesTrue`. + private func conditionText(provider: UsageProvider?) -> String { + guard let first = self.clauses.first else { return "" } + let mixed = Set(self.clauses.dropFirst().compactMap(\.combinator)).count > 1 + var text = Self.predicateText(first.predicate, provider: provider) + for clause in self.clauses.dropFirst() { + let joiner = (clause.combinator ?? .and) == .and + ? L("menu_bar_layout_conditional_and") + : L("menu_bar_layout_conditional_or") + let pred = Self.predicateText(clause.predicate, provider: provider) + text = mixed ? "(\(text)) \(joiner) \(pred)" : "\(text) \(joiner) \(pred)" + } + return text + } + + private static func predicateText( + _ predicate: MenuBarConditionalPredicate, + provider: UsageProvider?) + -> String + { + var metric = predicate.metric.editorLabel(provider: provider) + if predicate.metric.supportsDirection { + metric += " " + (predicate.direction == .used + ? L("menu_bar_layout_conditional_used") + : L("menu_bar_layout_conditional_remaining")) + } + let value = predicate.threshold + // Whole hours read as "2h"; a half-hour step needs the decimal to stay truthful. + let number = value == value.rounded() ? String(Int(value.rounded())) : String(format: "%.1f", value) + let unit = predicate.metric.thresholdUnit + let amount = predicate.metric.kind == .currencyUSD ? "\(number) \(unit)" : "\(number)\(unit)" + return "\(metric) \(predicate.comparison.symbol) \(amount)" + } + + func editorSummary(provider: UsageProvider?) -> String { + let condition = self.conditionText(provider: provider) + return L( + "menu_bar_layout_conditional_summary", + condition, + self.thenToken.editorLabel(provider: provider), + self.elseToken.editorLabel(provider: provider)) + } + + /// Generates a unique copy name that avoids collisions with existing library entries. + static func uniqueCopyName(basedOn name: String, existingNames: Set) -> String { + let base = name.trimmingCharacters(in: .whitespacesAndNewlines) + let stem = base.isEmpty ? L("menu_bar_layout_token_conditional") : base + var candidate = L("menu_bar_layout_conditional_copy_name", stem) + var n = 2 + while existingNames.contains(candidate.lowercased()) { + candidate = L("menu_bar_layout_conditional_copy_name_numbered", stem, n) + n += 1 + } + return candidate + } +} + +extension MenuBarConditionalMetric { + /// Reuses the palette token labels so a metric and the block it measures always read the same, and + /// resolves lane names through the provider's own labels rather than hard-coding "Primary". + func editorLabel(provider: UsageProvider?) -> String { + switch self { + case .session: L("menu_bar_layout_token_session") + case .weekly: L("menu_bar_layout_token_weekly") + case .scopedWeekly: L("menu_bar_layout_token_scoped_weekly") + case .automatic: L("menu_bar_layout_token_auto") + case .primaryLane: MenuBarLayoutToken.lanePercent(lane: .primary).editorLabel(provider: provider) + case .secondaryLane: MenuBarLayoutToken.lanePercent(lane: .secondary).editorLabel(provider: provider) + case .tertiaryLane: MenuBarLayoutToken.lanePercent(lane: .tertiary).editorLabel(provider: provider) + case .sessionResetsIn: L("menu_bar_layout_conditional_metric_resets_in", L("Session")) + case .weeklyResetsIn: L("menu_bar_layout_conditional_metric_resets_in", L("Weekly")) + case .scopedWeeklyResetsIn: L( + "menu_bar_layout_conditional_metric_resets_in", + L("menu_bar_layout_conditional_metric_scoped_weekly")) + case .automaticResetsIn: L("menu_bar_layout_conditional_metric_resets_in", L("Auto")) + case .sessionPace: L("menu_bar_layout_token_session_pace") + case .weeklyPace: L("menu_bar_layout_token_weekly_pace") + case .automaticPace: L("menu_bar_layout_token_auto_pace") + case .runsOutIn: L("menu_bar_layout_token_runs_out") + case .balance: L("Balance") + case .costToday: L("menu_bar_layout_token_cost_today") + case .cost30d: L("menu_bar_layout_token_cost_30d") + } + } +} diff --git a/Sources/CodexBar/MenuBarLayoutEditor.swift b/Sources/CodexBar/MenuBarLayoutEditor.swift index 74240d8b7..a619326f1 100644 --- a/Sources/CodexBar/MenuBarLayoutEditor.swift +++ b/Sources/CodexBar/MenuBarLayoutEditor.swift @@ -179,6 +179,7 @@ struct MenuBarLayoutEditor: View { @State private var scope: MenuBarLayoutEditorScope = .all @State private var selectedPosition: MenuBarLayoutPosition? + @State private var conditionalDraft: MenuBarLayoutConditionalDraft? private var layout: MenuBarLayout { switch self.scope { @@ -297,6 +298,8 @@ struct MenuBarLayoutEditor: View { self.palette(group) } + self.conditionalsPalette + Divider() self.displayOptions @@ -305,6 +308,17 @@ struct MenuBarLayoutEditor: View { .onDeleteCommand { self.removeSelectedToken() } + .sheet(item: self.$conditionalDraft) { draft in + MenuBarLayoutConditionalEditorSheet( + draft: draft, + provider: self.persistenceProvider, + existingNames: Set( + self.settings.menuBarLayoutConditionals.map { + $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + }), + onSave: self.saveConditionalDraft) + } + .onChange(of: self.scope) { _, _ in self.selectedPosition = nil } @@ -423,9 +437,7 @@ struct MenuBarLayoutEditor: View { self.selectedPosition = position } label: { MenuBarLayoutChipLabel( - title: token.editorLabel( - provider: self.persistenceProvider, - snapshot: self.persistenceSnapshot), + title: self.chipTitle(for: token), systemImage: token.editorSystemImage, isSelected: self.selectedPosition == position) .draggable(MenuBarLayoutDragItem.placed(token, at: position, in: self.layout)) @@ -439,9 +451,19 @@ struct MenuBarLayoutEditor: View { .dropDestination(for: MenuBarLayoutDragItem.self) { items, _ in self.insert(items.first, at: position) } - .accessibilityLabel(token.editorAccessibilityLabel( - provider: self.persistenceProvider, - snapshot: self.persistenceSnapshot)) + .accessibilityLabel(self.chipAccessibilityLabel(for: token)) + .contextMenu { + if case let .conditional(id) = token, + let conditional = self.settings.menuBarLayoutConditionals + .first(where: { $0.id == id }) + { + Button(L("menu_bar_layout_conditional_edit")) { + self.conditionalDraft = MenuBarLayoutConditionalDraft( + mode: .edit(id), + conditional: conditional) + } + } + } .accessibilityHint(L("menu_bar_layout_chip_hint")) .accessibilityAction(named: L("Remove")) { self.remove(at: position) @@ -554,6 +576,89 @@ struct MenuBarLayoutEditor: View { } } + private var conditionalsPalette: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(L("menu_bar_layout_group_conditionals")) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Spacer() + Button { + self.conditionalDraft = MenuBarLayoutConditionalDraft( + mode: .create, + conditional: .makeDefault()) + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.plain) + .help(L("menu_bar_layout_conditional_add")) + .accessibilityLabel(L("menu_bar_layout_conditional_add")) + } + + if self.settings.menuBarLayoutConditionals.isEmpty { + Text(L("menu_bar_layout_conditional_none")) + .font(.caption) + .foregroundStyle(.tertiary) + } else { + MenuBarLayoutChipFlowLayout(spacing: 6) { + ForEach(self.settings.menuBarLayoutConditionals, id: \.id) { conditional in + self.conditionalPaletteChip(conditional: conditional) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private func conditionalPaletteChip( + conditional: MenuBarLayoutConditional) + -> some View + { + Button { + self.write(MenuBarLayoutEditorMutations.append(.conditional(id: conditional.id), to: self.layout)) + } label: { + MenuBarLayoutChipLabel( + title: conditional.displayName, + systemImage: "switch.2", + isSelected: false) + } + .buttonStyle(.plain) + .draggable(MenuBarLayoutDragItem.palette(.conditional(id: conditional.id))) + .focusable() + .onKeyPress(keys: [.space, .return], phases: [.down]) { _ in + self.write(MenuBarLayoutEditorMutations.append(.conditional(id: conditional.id), to: self.layout)) + return .handled + } + .accessibilityLabel(conditional.displayName) + .contextMenu { + Button(L("menu_bar_layout_conditional_edit")) { + self.conditionalDraft = MenuBarLayoutConditionalDraft( + mode: .edit(conditional.id), + conditional: conditional) + } + Button(L("menu_bar_layout_conditional_duplicate")) { + self.duplicateConditional(conditional) + } + Button(L("menu_bar_layout_conditional_remove"), role: .destructive) { + self.settings.removeMenuBarLayoutConditional(id: conditional.id) + } + } + } + + private func duplicateConditional(_ conditional: MenuBarLayoutConditional) { + let existingNames = Set(self.settings.menuBarLayoutConditionals.map { $0.name.lowercased() }) + let copyName = MenuBarLayoutConditional.uniqueCopyName( + basedOn: conditional.name, + existingNames: existingNames) + let copy = MenuBarLayoutConditional( + name: copyName, + clauses: conditional.clauses, + thenToken: conditional.thenToken, + elseToken: conditional.elseToken) + self.settings.menuBarLayoutConditionals.append(copy) + } + private var displayOptions: some View { HStack(spacing: 18) { Picker(L("menu_bar_layout_size"), selection: self.sizeBinding) { @@ -626,6 +731,35 @@ struct MenuBarLayoutEditor: View { self.selectedPosition = nil } + private func saveConditionalDraft(_ draft: MenuBarLayoutConditionalDraft) { + switch draft.mode { + case .create: + self.settings.menuBarLayoutConditionals.append(draft.conditional) + case let .edit(id): + guard let index = self.settings.menuBarLayoutConditionals.firstIndex(where: { $0.id == id }) + else { return } + self.settings.menuBarLayoutConditionals[index] = draft.conditional + } + } + + private func chipTitle(for token: MenuBarLayoutToken) -> String { + if case let .conditional(id) = token { + return self.settings.menuBarLayoutConditionals + .first(where: { $0.id == id })?.displayName + ?? L("menu_bar_layout_token_conditional") + } + return token.editorLabel(provider: self.persistenceProvider, snapshot: self.persistenceSnapshot) + } + + private func chipAccessibilityLabel(for token: MenuBarLayoutToken) -> String { + if case .conditional = token { + return self.chipTitle(for: token) + } + return token.editorAccessibilityLabel( + provider: self.persistenceProvider, + snapshot: self.persistenceSnapshot) + } + private func write(_ layout: MenuBarLayout) { MenuBarLayoutEditorPersistence.activate( layout, @@ -634,7 +768,7 @@ struct MenuBarLayoutEditor: View { } } -private struct MenuBarLayoutChipLabel: View { +struct MenuBarLayoutChipLabel: View { let title: String let systemImage: String let isSelected: Bool @@ -659,6 +793,76 @@ private struct MenuBarLayoutChipLabel: View { } } +/// Left-aligned wrapping row layout for palette chips. +/// +/// The conditionals palette holds user-named chips of widely varying width. An adaptive +/// `LazyVGrid` would size them into equal columns and spread the leftover pane width between +/// them, and a plain `HStack` would push later chips outside the settings pane; this places each +/// chip at its natural width and wraps to the next row. +struct MenuBarLayoutChipFlowLayout: Layout { + var spacing: CGFloat = 6 + + /// Pure packing rule: subview indices grouped into rows that each stay within `maxWidth`. + /// Kept separate from `Layout` so the wrapping contract is testable without faking subviews. + /// A chip wider than `maxWidth` still occupies its own row rather than being dropped. + static func rows(widths: [CGFloat], maxWidth: CGFloat, spacing: CGFloat) -> [[Int]] { + var rows: [[Int]] = [] + var current: [Int] = [] + var currentWidth: CGFloat = 0 + for (index, width) in widths.enumerated() { + let advance = current.isEmpty ? width : spacing + width + if !current.isEmpty, currentWidth + advance > maxWidth { + rows.append(current) + current = [index] + currentWidth = width + continue + } + current.append(index) + currentWidth += advance + } + if !current.isEmpty { + rows.append(current) + } + return rows + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let rows = Self.rows( + widths: sizes.map(\.width), + maxWidth: proposal.width ?? .infinity, + spacing: self.spacing) + let rowWidths = rows.map { row in + row.reduce(CGFloat.zero) { $0 + sizes[$1].width } + + CGFloat(max(0, row.count - 1)) * self.spacing + } + let rowHeights = rows.map { row in + row.reduce(CGFloat.zero) { max($0, sizes[$1].height) } + } + return CGSize( + width: rowWidths.max() ?? 0, + height: rowHeights.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * self.spacing) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let rows = Self.rows(widths: sizes.map(\.width), maxWidth: bounds.width, spacing: self.spacing) + var y = bounds.minY + for row in rows { + let rowHeight = row.reduce(CGFloat.zero) { max($0, sizes[$1].height) } + var x = bounds.minX + for index in row { + let size = sizes[index] + subviews[index].place( + at: CGPoint(x: x, y: y + (rowHeight - size.height) / 2), + proposal: ProposedViewSize(size)) + x += size.width + self.spacing + } + y += rowHeight + self.spacing + } + } +} + @MainActor struct MenuBarLayoutPreview: View { let layout: MenuBarLayout @@ -683,6 +887,7 @@ struct MenuBarLayoutPreview: View { size: self.settings.menuBarLayoutSize, highContrast: self.settings.menuBarHighContrastOnInactiveDisplays, showUsed: self.settings.usageBarsShowUsed, + conditionals: self.settings.menuBarLayoutConditionals, appearanceName: "preview", isDebugApp: false, now: minute, @@ -736,11 +941,29 @@ struct MenuBarLayoutPreview: View { window: rawAutomatic) let scopedNamed = MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot) let paceWindow = weekly ?? automatic - let runsOut = paceWindow - .flatMap { self.store.weeklyPace(provider: provider, window: $0, now: now) } + // Bind the pace itself: `etaSeconds` is the numeric run-out conditional predicates compare. + let pace = paceWindow.flatMap { + self.store.weeklyPace( + provider: provider, + window: $0, + now: now) + } + let runsOut = pace .flatMap { UsagePaceText.weeklyDetail(provider: provider, pace: $0, now: now).rightLabel } let cost = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot let costToday = MenuBarLayoutCostResolver.todayCostUSD(snapshot: cost, now: now) + let balanceAmounts = MenuBarLayoutBalanceResolver.balanceAmountsUSD( + provider: provider, + snapshot: snapshot) + // Thresholds are USD, and `convertedCost` returns the source amount unchanged when no rate + // exists, so keep the datum only when the conversion actually landed in USD. + let toUSD = { (value: Double) -> Double? in + let converted = UsageFormatter.convertedCost( + value, + preferredCurrency: "USD", + providerCurrency: cost?.currencyCode) + return converted.currencyCode == "USD" ? converted.value : nil + } let automaticRenderWindow = MenuBarLayoutRenderWindow(automatic) return MenuBarLayoutRenderData( iconKey: provider.rawValue, @@ -760,7 +983,11 @@ struct MenuBarLayoutPreview: View { ? StatusItemController.mistralSpendDisplayText(snapshot: snapshot) : nil, sessionPace: self.store.menuBarLayoutPaceText(provider: provider, window: session, now: now), - weeklyPace: self.store.menuBarLayoutPaceText(provider: provider, window: weekly, now: now), + weeklyPace: self.store.menuBarLayoutPaceText( + provider: provider, + window: weekly, + now: now, + minimumElapsedPercent: 1), automaticPace: self.store.menuBarLayoutPaceText(provider: provider, window: automatic, now: now), runsOut: runsOut, balance: MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot), @@ -769,7 +996,26 @@ struct MenuBarLayoutPreview: View { }, cost30d: cost?.last30DaysCostUSD.map { UsageFormatter.currencyString($0, currencyCode: cost?.currencyCode ?? "USD") - }) + }, + metrics: MenuBarLayoutRenderMetrics( + sessionPaceDelta: self.store.menuBarLayoutPaceDelta( + provider: provider, + window: session, + now: now), + weeklyPaceDelta: self.store.menuBarLayoutPaceDelta( + provider: provider, + window: weekly, + now: now, + minimumElapsedPercent: 1), + automaticPaceDelta: self.store.menuBarLayoutPaceDelta( + provider: provider, + window: automatic, + now: now), + runsOutMinutes: pace?.etaSeconds.map { Int(($0 / 60).rounded()) }, + balanceRemainingUSD: balanceAmounts.remaining, + balanceUsedUSD: balanceAmounts.used, + costTodayUSD: costToday.flatMap(toUSD), + cost30dUSD: cost?.last30DaysCostUSD.flatMap(toUSD))) } private func representativeData(provider: UsageProvider) -> MenuBarLayoutRenderData { @@ -794,6 +1040,9 @@ struct MenuBarLayoutPreview: View { let samplePace = { (window: RateWindow) -> String? in MenuBarDisplayText.paceText(pace: UsagePace.weekly(window: window, now: now)) } + let samplePaceDelta = { (window: RateWindow) -> Double? in + UsagePace.weekly(window: window, now: now)?.deltaPercent.rounded() + } return MenuBarLayoutRenderData( iconKey: "\(provider.rawValue)-representative", providerName: L(self.store.metadata(for: provider).displayName), @@ -815,7 +1064,17 @@ struct MenuBarLayoutPreview: View { // Provider-specific by design: only OpenRouter previews the Balance palette token. balance: provider == .openrouter ? "$12.34" : nil, costToday: "$1.25", - cost30d: "$20.00") + cost30d: "$20.00", + metrics: MenuBarLayoutRenderMetrics( + sessionPaceDelta: samplePaceDelta(session), + weeklyPaceDelta: samplePaceDelta(weekly), + automaticPaceDelta: samplePaceDelta(session), + // 1d 16h == 40h, matching the sample `runsOut` text above. + runsOutMinutes: 2400, + balanceRemainingUSD: provider == .openrouter ? 12.34 : nil, + balanceUsedUSD: provider == .openrouter ? 7.66 : nil, + costTodayUSD: 1.25, + cost30dUSD: 20)) } } @@ -938,6 +1197,8 @@ extension MenuBarLayoutToken { case .cost30d: L("menu_bar_layout_token_cost_30d") case .separatorDot: "·" case .space: L("menu_bar_layout_token_space") + case .conditional: L("menu_bar_layout_token_conditional") + case .hidden: L("menu_bar_layout_conditional_hide") } } @@ -975,6 +1236,8 @@ extension MenuBarLayoutToken { case .cost30d: "calendar.badge.clock" case .separatorDot: "smallcircle.filled.circle" case .space: "space" + case .conditional: "switch.2" + case .hidden: "eye.slash" } } } diff --git a/Sources/CodexBar/MenuBarLayoutRenderer.swift b/Sources/CodexBar/MenuBarLayoutRenderer.swift index a11d3d074..7e6eca260 100644 --- a/Sources/CodexBar/MenuBarLayoutRenderer.swift +++ b/Sources/CodexBar/MenuBarLayoutRenderer.swift @@ -21,6 +21,37 @@ struct MenuBarLayoutRenderWindow: Hashable { } } +/// Numeric values behind the display strings on `MenuBarLayoutRenderData`. The strings are formatted +/// for the menu bar and cannot be compared, so conditional predicates read these instead. +/// +/// Pace deltas and `runsOutMinutes` are deliberately pre-rounded to the same granularity as the text +/// they mirror (`MenuBarDisplayText.paceText` rounds to whole percentage points): an unrounded value +/// drifts on every clock tick and would defeat `MenuBarLayoutTitleCache`, which keys on this struct. +struct MenuBarLayoutRenderMetrics: Hashable { + /// Signed pace delta in whole percentage points, matching the rendered `+11%` / `-8%` text. + let sessionPaceDelta: Double? + let weeklyPaceDelta: Double? + let automaticPaceDelta: Double? + /// Whole minutes until the projected run-out (`UsagePace.etaSeconds`). + let runsOutMinutes: Int? + /// USD amounts mirroring `balance` / `costToday` / `cost30d`. Provider amounts reported in another + /// currency are converted to USD so thresholds do not move when the user's display currency does. + let balanceRemainingUSD: Double? + let balanceUsedUSD: Double? + let costTodayUSD: Double? + let cost30dUSD: Double? + + static let unavailable = MenuBarLayoutRenderMetrics( + sessionPaceDelta: nil, + weeklyPaceDelta: nil, + automaticPaceDelta: nil, + runsOutMinutes: nil, + balanceRemainingUSD: nil, + balanceUsedUSD: nil, + costTodayUSD: nil, + cost30dUSD: nil) +} + struct MenuBarLayoutRenderData: Hashable { let iconKey: String let providerName: String? @@ -48,12 +79,15 @@ struct MenuBarLayoutRenderData: Hashable { let balance: String? let costToday: String? let cost30d: String? + /// Numeric twins of the display strings above, for conditional predicates. + let metrics: MenuBarLayoutRenderMetrics } struct MenuBarLayoutRenderOptions: Hashable { let size: MenuBarLayoutSize let highContrast: Bool let showUsed: Bool + let conditionals: [MenuBarLayoutConditional] let appearanceName: String let isDebugApp: Bool /// Whether the provider's latest refresh failed; when true the shown snapshot is stale @@ -70,6 +104,7 @@ struct MenuBarLayoutRenderOptions: Hashable { size: MenuBarLayoutSize, highContrast: Bool, showUsed: Bool, + conditionals: [MenuBarLayoutConditional], appearanceName: String, isDebugApp: Bool, isStale: Bool = false, @@ -79,6 +114,7 @@ struct MenuBarLayoutRenderOptions: Hashable { self.size = size self.highContrast = highContrast self.showUsed = showUsed + self.conditionals = conditionals self.appearanceName = appearanceName self.isDebugApp = isDebugApp self.isStale = isStale @@ -93,11 +129,16 @@ struct MenuBarLayoutRenderKey: Hashable { let size: MenuBarLayoutSize let highContrast: Bool let showUsed: Bool + let conditionals: [MenuBarLayoutConditional] let appearanceName: String let isDebugApp: Bool let isStale: Bool let verticalAdjustment: Int let resetText: MenuBarLayoutResetText + /// Truth value per conditional id. Predicates can read the clock (time to reset), so two renders + /// with identical data and reset text can still need different branches; keying on the outcomes + /// keeps the cache correct without putting `now` — which ticks constantly — into the key. + let conditionalOutcomes: [UUID: Bool] } struct MenuBarLayoutResetText: Hashable { @@ -186,19 +227,31 @@ final class MenuBarLayoutRenderer { -> MenuBarLayoutRenderedTitle { let resetText = MenuBarLayoutResetText(window: data.automatic, now: options.now) + // Evaluate each conditional exactly once per render: the outcome is both a cache-key component + // and what the token resolver needs, so re-testing per placement would only duplicate work. + let outcomes = Dictionary( + options.conditionals.map { ($0.id, $0.evaluatesTrue(data: data, now: options.now)) }, + uniquingKeysWith: { first, _ in first }) let key = MenuBarLayoutRenderKey( layout: layout, data: data, size: options.size, highContrast: options.highContrast, showUsed: options.showUsed, + conditionals: options.conditionals, appearanceName: options.appearanceName, isDebugApp: options.isDebugApp, isStale: options.isStale, verticalAdjustment: options.verticalAdjustment, - resetText: resetText) + resetText: resetText, + conditionalOutcomes: outcomes) return self.cache.value(for: key) { - Self.renderUncached(layout: layout, data: data, icon: icon, options: options) + Self.renderUncached( + layout: layout, + data: data, + icon: icon, + options: options, + outcomes: outcomes) } } @@ -210,10 +263,31 @@ final class MenuBarLayoutRenderer { layout: MenuBarLayout, data: MenuBarLayoutRenderData, icon: NSImage?, - options: MenuBarLayoutRenderOptions) + options: MenuBarLayoutRenderOptions, + outcomes: [UUID: Bool]) -> MenuBarLayoutRenderedTitle { - let isStacked = layout.lines.count == 2 + let conditionalsByID = Dictionary( + options.conditionals.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first }) + + // Pre-resolve conditionals so .hidden branches vanish and adjacent-space checks see + // only the tokens that will actually render. A line left with nothing to render is dropped + // entirely: keeping it would emit a stray newline, hold the title in stacked typography, + // and announce a blank line to VoiceOver. + let renderedLines = layout.lines + .map { line in + line.compactMap { + Self.resolvedDisplayToken( + $0, + data: data, + conditionals: conditionalsByID, + outcomes: outcomes) + } + } + .filter { !$0.isEmpty } + + let isStacked = renderedLines.count == 2 let font = NSFont.systemFont(ofSize: Self.fontSize(size: options.size, isStacked: isStacked)) let foregroundColor = if options.highContrast { NSColor.labelColor @@ -239,25 +313,25 @@ final class MenuBarLayoutRenderer { attributes[.baselineOffset] = baseBaselineOffset + CGFloat(options.verticalAdjustment) let result = NSMutableAttributedString() var accessibilityLines: [String] = [] - // Only surface a leading icon via `button.image` when an actual image is available and the - // high-contrast contract does not require the icon to stay inside the attributed title. - // AppKit dims `button.image` on inactive displays, but high-contrast layouts keep icon + text - // together in one attributed path so the existing dimming contract is preserved. With a - // missing icon the token still renders its placeholder inside the title. - let leadingIcon: NSImage? = if options.highContrast { + + // AppKit positions `button.image` horizontally beside the entire title, so stacked layouts + // must keep their icon inline to preserve which row owns it. High-contrast layouts also + // keep icon + text together, while single-line layouts surface the icon for native dimming. + // With a missing icon the token still renders its placeholder inside the title. + let leadingIcon: NSImage? = if options.highContrast || isStacked { nil - } else if layout.lines.first?.first == .icon, icon != nil { + } else if renderedLines.first?.first == .icon, icon != nil { icon.map { Self.offsetLeadingIcon($0, adjustment: options.verticalAdjustment) } } else { nil } - for (lineIndex, line) in layout.lines.enumerated() { + for (lineIndex, renderedLine) in renderedLines.enumerated() { if lineIndex > 0 { result.append(NSAttributedString(string: "\n", attributes: attributes)) } var accessibilityParts: [String] = [] - for (tokenIndex, token) in line.enumerated() { + for (tokenIndex, token) in renderedLine.enumerated() { // The leading icon is surfaced as `button.image` so AppKit applies the system's // active/inactive display tinting; it is not repeated inside the attributed title, // but its accessibility description must survive for VoiceOver. @@ -265,7 +339,7 @@ final class MenuBarLayoutRenderer { accessibilityParts.append(Self.iconAccessibilityText(data: data)) continue } - if tokenIndex > 0, token != .space, line[tokenIndex - 1] != .space { + if tokenIndex > 0, token != .space, renderedLine[tokenIndex - 1] != .space { result.append(NSAttributedString(string: "\u{2009}", attributes: attributes)) } let renderedItem = Self.renderItem( @@ -288,7 +362,12 @@ final class MenuBarLayoutRenderer { if options.isDebugApp { result.append(NSAttributedString(string: " D", attributes: attributes)) - accessibilityLines[accessibilityLines.count - 1].append(", \(L("Debug"))") + // Every line can collapse when a conditional hides the only token in the layout. + if accessibilityLines.isEmpty { + accessibilityLines.append(L("Debug")) + } else { + accessibilityLines[accessibilityLines.count - 1].append(", \(L("Debug"))") + } } let accessibilityLabel = accessibilityLines.enumerated().map { index, line in index == 0 ? line : "\(L("menu_bar_layout_line", index + 1)), \(line)" @@ -299,6 +378,34 @@ final class MenuBarLayoutRenderer { leadingIcon: leadingIcon) } + /// nil == resolved to .hidden (render nothing, no separator). A returned .conditional + /// means the id is dangling or the depth cap was hit; renderItem shows the placeholder. + private static func resolvedDisplayToken( + _ token: MenuBarLayoutToken, + data: MenuBarLayoutRenderData, + conditionals: [UUID: MenuBarLayoutConditional], + outcomes: [UUID: Bool], + depth: Int = 0) + -> MenuBarLayoutToken? + { + switch token { + case .hidden: return nil + case let .conditional(id): + guard depth < MenuBarLayoutToken.maxConditionalDepth, + let conditional = conditionals[id], + let isTrue = outcomes[id] + else { return token } + let branch = isTrue ? conditional.thenToken : conditional.elseToken + return self.resolvedDisplayToken( + branch, + data: data, + conditionals: conditionals, + outcomes: outcomes, + depth: depth + 1) + default: return token + } + } + private static func renderItem( _ item: MenuBarLayoutToken, data: MenuBarLayoutRenderData, @@ -337,33 +444,7 @@ final class MenuBarLayoutRenderer { value.addAttributes(style.attributes, range: NSRange(location: 0, length: value.length)) return (value, Self.iconAccessibilityText(data: data)) case let .percent(window): - let rateWindow = Self.window(window, data: data) - let resolvedValue = Self.percentValue( - window: window, - rateWindow: rateWindow, - automaticText: data.automaticText, - showUsed: options.showUsed) - let prefix: String - let accessibilityPrefix: String - switch window { - case .session: - prefix = Self.sessionPrefix(rateWindow) - accessibilityPrefix = L("Session") - case .weekly: - accessibilityPrefix = data.laneLabels.secondary - prefix = String(accessibilityPrefix.prefix(1)).uppercased() - case .scopedWeekly: - prefix = data.scopedWeeklyTitle.map { String($0.prefix(1)).uppercased() } ?? "F" - accessibilityPrefix = data.scopedWeeklyTitle ?? L("Scoped weekly") - case .automatic: - prefix = "" - accessibilityPrefix = L("Usage") - } - let display = prefix.isEmpty ? resolvedValue.text : "\(prefix) \(resolvedValue.text)" - let accessibility = resolvedValue.isAvailable - ? L("%@ %@", accessibilityPrefix, resolvedValue.text) - : L("%@ unavailable", accessibilityPrefix) - return self.textToken(display, accessibilityText: accessibility, attributes: style.attributes) + return self.renderPercent(window, data: data, style: style, options: options) case let .pace(window): return self.optionalTextToken( Self.pace(window, data: data), @@ -422,11 +503,55 @@ final class MenuBarLayoutRenderer { return self.textToken("·", accessibilityText: nil, attributes: style.attributes) case .space: return self.textToken(" ", accessibilityText: nil, attributes: style.attributes) + case .hidden: + return self.textToken("", accessibilityText: nil, attributes: style.attributes) + case .conditional: + // Reachable only for dangling id or depth cap; the resolver already expanded known conditionals. + return self.textToken( + self.missingValue, + accessibilityText: L("menu_bar_layout_conditional_unavailable"), + attributes: style.attributes) case .providerName, .accountLabel, .lanePercent: preconditionFailure("Provider text tokens should render before the main switch") } } + private static func renderPercent( + _ window: PercentWindow, + data: MenuBarLayoutRenderData, + style: TokenStyle, + options: MenuBarLayoutRenderOptions) + -> (value: NSAttributedString, accessibilityText: String?) + { + let rateWindow = Self.window(window, data: data) + let resolvedValue = Self.percentValue( + window: window, + rateWindow: rateWindow, + automaticText: data.automaticText, + showUsed: options.showUsed) + let prefix: String + let accessibilityPrefix: String + switch window { + case .session: + prefix = Self.sessionPrefix(rateWindow) + accessibilityPrefix = L("Session") + case .weekly: + accessibilityPrefix = data.laneLabels.secondary + prefix = String(accessibilityPrefix.prefix(1)).uppercased() + case .scopedWeekly: + prefix = data.scopedWeeklyTitle.map { String($0.prefix(1)).uppercased() } ?? "F" + accessibilityPrefix = data.scopedWeeklyTitle ?? L("Scoped weekly") + case .automatic: + prefix = "" + accessibilityPrefix = L("Usage") + } + let display = prefix.isEmpty ? resolvedValue.text : "\(prefix) \(resolvedValue.text)" + let accessibility = resolvedValue.isAvailable + ? L("%@ %@", accessibilityPrefix, resolvedValue.text) + : L("%@ unavailable", accessibilityPrefix) + return self.textToken(display, accessibilityText: accessibility, attributes: style.attributes) + } + private static func iconAccessibilityText(data: MenuBarLayoutRenderData) -> String { L("%@ icon", data.providerName ?? L("Provider")) } @@ -664,3 +789,84 @@ final class MenuBarLayoutRenderer { return size == .small ? 14 : 18 } } + +extension MenuBarLayoutConditional { + /// Left-fold over clauses; a predicate whose datum is unavailable evaluates false. + /// + /// `now` is a parameter rather than a field on `MenuBarLayoutRenderData` because the render data is + /// a cache key: putting a constantly ticking clock in it would defeat `MenuBarLayoutTitleCache`. + func evaluatesTrue(data: MenuBarLayoutRenderData, now: Date) -> Bool { + guard let first = self.clauses.first else { return false } + var result = Self.test(first.predicate, data: data, now: now) + for clause in self.clauses.dropFirst() { + let value = Self.test(clause.predicate, data: data, now: now) + switch clause.combinator { + case .or: result = result || value + case .and, .none: result = result && value + } + } + return result + } + + private static func test( + _ predicate: MenuBarConditionalPredicate, + data: MenuBarLayoutRenderData, + now: Date) + -> Bool + { + guard let value = Self.value(for: predicate, in: data, now: now) else { return false } + return predicate.comparison.evaluate(value, predicate.threshold) + } + + /// nil == the datum is unavailable, so the predicate evaluates false instead of comparing a + /// fabricated zero. Percent and reset readings come from the render windows; pace, run-out, balance + /// and cost come from `data.metrics`, whose display strings cannot be compared numerically. + private static func value( + for predicate: MenuBarConditionalPredicate, + in data: MenuBarLayoutRenderData, + now: Date) + -> Double? + { + switch predicate.metric { + case .session: self.percent(data.session, predicate.direction) + case .weekly: self.percent(data.weekly, predicate.direction) + case .scopedWeekly: self.percent(data.scopedWeekly, predicate.direction) + case .automatic: self.percent(data.automatic, predicate.direction) + case .primaryLane: self.percent(data.primary, predicate.direction) + case .secondaryLane: self.percent(data.secondary, predicate.direction) + case .tertiaryLane: self.percent(data.tertiary, predicate.direction) + case .sessionResetsIn: self.hoursUntilReset(data.session, now: now) + case .weeklyResetsIn: self.hoursUntilReset(data.weekly, now: now) + case .scopedWeeklyResetsIn: self.hoursUntilReset(data.scopedWeekly, now: now) + case .automaticResetsIn: self.hoursUntilReset(data.automatic, now: now) + case .sessionPace: data.metrics.sessionPaceDelta + case .weeklyPace: data.metrics.weeklyPaceDelta + case .automaticPace: data.metrics.automaticPaceDelta + case .runsOutIn: data.metrics.runsOutMinutes.map { Double($0) / 60 } + case .balance: predicate.direction == .used + ? data.metrics.balanceUsedUSD + : data.metrics.balanceRemainingUSD + case .costToday: data.metrics.costTodayUSD + case .cost30d: data.metrics.cost30dUSD + } + } + + private static func percent( + _ window: MenuBarLayoutRenderWindow?, + _ direction: MenuBarConditionalDirection) + -> Double? + { + guard let window else { return nil } + return direction == .used ? window.usedPercent : window.remainingPercent + } + + /// Hours until the window resets. A window with no reset timestamp — or one whose reset already + /// passed, meaning the snapshot has not caught up yet — has no countdown to compare, so the + /// predicate evaluates false rather than firing a "resets soon" branch off stale data. + private static func hoursUntilReset(_ window: MenuBarLayoutRenderWindow?, now: Date) -> Double? { + guard let resetsAt = window?.resetsAt else { return nil } + let seconds = resetsAt.timeIntervalSince(now) + guard seconds > 0 else { return nil } + return seconds / 3600 + } +} diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index d5a3f8590..c2983ac49 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -194,6 +194,10 @@ extension UsageMenuCardView.Model { presentation.resetText = regen.resetText Self.apply(regen.pace, to: &presentation) } + // Provider-specific by design: DeepSeek's balance description is provider-owned copy localized here. + if input.provider == .deepseek, let detail = presentation.detailText { + presentation.detailText = Self.localizedDeepSeekBalanceDescription(detail) + } if policy.movesPrimaryDetailToStatus(snapshot: input.snapshot) { presentation.statusText = presentation.detailText presentation.detailText = nil @@ -526,7 +530,7 @@ extension UsageMenuCardView.Model { snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) { if input.provider == .factory, snapshot.tertiary != nil { - return ("5-hour", L("Weekly"), L("Monthly"), true) + return (L("5-hour"), L("Weekly"), L("Monthly"), true) } let cursorLabels = input.provider == .cursor ? Self.cursorRateWindowLabels( @@ -563,7 +567,7 @@ extension UsageMenuCardView.Model { input.metadata.weeklyLabel } return ( - L(primaryLabel), + localizedSessionQuotaLabel(primaryLabel, windowMinutes: snapshot.primary?.windowMinutes), L(secondaryLabel), input.metadata.opusLabel.map(L) ?? L("Sonnet"), input.metadata.supportsOpus) @@ -958,6 +962,16 @@ extension UsageMenuCardView.Model { let title = input.provider == .doubao && namedWindow.id.contains("-team-") ? "\(L(namedWindow.title)) (\(L("Team")))" : L(namedWindow.title) + // Provider-specific by design: Kiro overage remaining copy is unique to that extra window. + let detailLeftText: String? = if usageKnown { + Self.kiroOverageRemainingDetail( + snapshot: snapshot, + namedWindow: namedWindow, + provider: input.provider) + ?? paceDetail?.leftLabel + } else { + nil + } return Metric( id: namedWindow.id, title: title, @@ -969,7 +983,7 @@ extension UsageMenuCardView.Model { statusText: statusText, resetText: usageKnown ? resetText : nil, detailText: usageKnown ? detailText : nil, - detailLeftText: usageKnown ? paceDetail?.leftLabel : nil, + detailLeftText: detailLeftText, detailRightText: usageKnown ? paceDetail?.rightLabel : nil, pacePercent: usageKnown ? paceDetail?.pacePercent : nil, detailIsPaceDerived: paceDetail?.isPaceDerived ?? false, @@ -988,6 +1002,21 @@ extension UsageMenuCardView.Model { namedWindow.id == CodexAdditionalRateLimitMapper.sparkWeeklyWindowID } + private static func kiroOverageRemainingDetail( + snapshot: UsageSnapshot, + namedWindow: NamedRateWindow, + provider: UsageProvider) -> String? + { + guard provider == .kiro, namedWindow.id == "kiro-overage", + let remaining = snapshot.detailRow(label: "Overage credits left")?.value, + let capPhrase = snapshot.detailRow(label: "Overage usage")?.secondaryValue, + capPhrase.hasPrefix("of ") + else { return nil } + let total = String(capPhrase.dropFirst(3)) + guard !total.isEmpty else { return nil } + return String(format: L("%@ of %@ credits left"), remaining, total) + } + private static func isClaudeDailyRoutinesRateWindow(_ namedWindow: NamedRateWindow) -> Bool { namedWindow.id == "claude-routines" } diff --git a/Sources/CodexBar/MenuCardView+ProviderDetailLocalization.swift b/Sources/CodexBar/MenuCardView+ProviderDetailLocalization.swift new file mode 100644 index 000000000..bb2019fe7 --- /dev/null +++ b/Sources/CodexBar/MenuCardView+ProviderDetailLocalization.swift @@ -0,0 +1,187 @@ +import CodexBarCore +import Foundation + +extension UsageMenuCardView.Model { + static func localizedProviderDetails( + _ details: [ProviderDetailSection], + provider: UsageProvider) -> [ProviderDetailSection] + { + // Provider-specific by design: DeepSeek, z.ai, and Kiro rewrite unit phrasing. + // Other providers localize section titles and row labels through the shared catalog; values stay canonical. + guard provider == .deepseek || provider == .zai || provider == .kiro else { + return details.compactMap { section in + let rows = section.rows.compactMap { row in + try? ProviderDetailSection.Row( + label: L(row.label), + value: row.value, + secondaryValue: row.secondaryValue) + } + return try? ProviderDetailSection( + title: section.title.map(L), + rows: rows, + chart: section.chart) + } + } + return details.compactMap { section in + let rows = section.rows.compactMap { row in + try? ProviderDetailSection.Row( + label: L(row.label), + value: self.localizedProviderDetailValue(row.value, provider: provider), + secondaryValue: row.secondaryValue.map { + self.localizedProviderDetailValue($0, provider: provider) + }) + } + let chart = section.chart.flatMap { chart in + try? ProviderDetailSection.Chart( + kind: chart.kind, + title: chart.title.map(L), + unit: chart.unit.map(L), + points: chart.points) + } + return try? ProviderDetailSection( + title: section.title.map(L), + rows: rows, + chart: chart) + } + } + + static func localizedDeepSeekBalanceDescription(_ description: String) -> String { + let unavailable = "Balance unavailable for API calls" + if description == unavailable { + return L(unavailable) + } + + let addCreditsSeparator = " — add credits at " + if let range = description.range(of: addCreditsSeparator) { + return L( + "%@ — add credits at %@", + String(description[.. String? { + guard window.resetsAt == nil, + window.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) == "5-hour" + else { + return nil + } + return L("Resets every 5 hours") + } + + /// Provider-specific by design: DeepSeek, z.ai, and Kiro detail values carry provider-owned unit phrasing that + /// localizes at the presentation boundary without touching other providers. + private static func localizedProviderDetailValue(_ value: String, provider: UsageProvider) -> String { + switch provider { + case .deepseek: + self.localizedTokenSuffix(value) + case .zai: + self.localizedZaiValue(value) + case .kiro: + self.localizedKiroCapPhrase(value) + default: + value + } + } + + private static func localizedKiroCapPhrase(_ value: String) -> String { + let prefix = "of " + if value.hasPrefix(prefix) { + return L("of %@", String(value.dropFirst(prefix.count))) + } + let suffix = " credits" + guard value.hasSuffix(suffix) else { return value } + return "\(String(value.dropLast(suffix.count))) \(L("credits"))" + } + + private static func localizedTokenSuffix(_ value: String) -> String { + let suffix = " tokens" + guard value.hasSuffix(suffix) else { return value } + return L("%@ tokens", String(value.dropLast(suffix.count))) + } + + private static func localizedZaiValue(_ value: String) -> String { + if value == "Peak" || value == "Off-peak" { + return L(value) + } + for rate in ["peak", "off-peak"] { + let prefix = "\(rate) " + if value.hasPrefix(prefix) { + let countdown = String(value.dropFirst(prefix.count)) + return "\(L(rate)) \(self.localizedZaiCountdown(countdown))" + } + } + + let usedSuffix = " used" + if value.hasSuffix(usedSuffix) { + return L("%@ used", String(value.dropLast(usedSuffix.count))) + } + + let limitSeparator = " limit · " + let remainingSuffix = " remaining" + if let limitRange = value.range(of: limitSeparator), + value.hasSuffix(remainingSuffix) + { + let limit = String(value[.. String { + guard value.hasPrefix("in ") else { return L(value) } + let parts = value.dropFirst(3).split(separator: " ").map(String.init) + if parts.count == 2 { + let first = parts[0] + let second = parts[1] + if first.hasSuffix("d"), second.hasSuffix("h") { + return L("in %@d %@h", String(first.dropLast()), String(second.dropLast())) + } + if first.hasSuffix("d"), second.hasSuffix("m") { + return L("in %@d %@m", String(first.dropLast()), String(second.dropLast())) + } + if first.hasSuffix("h"), second.hasSuffix("m") { + return L("in %@h %@m", String(first.dropLast()), String(second.dropLast())) + } + } + if parts.count == 1 { + let part = parts[0] + if part.hasSuffix("d") { + return L("in %@d", String(part.dropLast())) + } + if part.hasSuffix("h") { + return L("in %@h", String(part.dropLast())) + } + if part.hasSuffix("m") { + return L("in %@m", String(part.dropLast())) + } + } + return value + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 278d70005..f38df2408 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1065,6 +1065,7 @@ extension UsageMenuCardView.Model { if input.provider == .sub2api { details = Self.sub2APILocalizedDetails(details) } + details = Self.localizedProviderDetails(details, provider: input.provider) guard input.hidePersonalInfo else { return details } return details.compactMap { section in let rows = section.rows.compactMap { row in @@ -1094,25 +1095,29 @@ extension UsageMenuCardView.Model { } } - private static func email( - for provider: UsageProvider, - snapshot: UsageSnapshot?, - account: AccountInfo, - metadata: ProviderMetadata, - accountIsAuthoritative: Bool) -> String - { - if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { + private static func email(from input: Input) -> String { + // Provider-specific by design: claude-swap accountOverride is the display label + // (alias or email · org). Other stacked sources keep fetched identity first. + if input.accountIsAuthoritative, + input.provider == .claude, + input.sourceLabel == ClaudeSwapAccountProjection.sourceLabel, + let email = input.account.email, !email.isEmpty + { + return email + } + if let email = input.snapshot?.accountEmail(for: input.provider), !email.isEmpty { return email } // Provider-specific by design: Cursor app auth can expose only a subject ID, so its card needs this fallback. - if provider == .cursor, - let accountID = snapshot?.identity(for: .cursor)?.accountID?.trimmingCharacters(in: .whitespacesAndNewlines), - !accountID.isEmpty - { - return accountID.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init) ?? accountID + if input.provider == .cursor { + let raw = input.snapshot?.identity(for: .cursor)?.accountID + let accountID = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !accountID.isEmpty { + return accountID.split(separator: "|").last.map(String.init) ?? accountID + } } - if metadata.usesAccountFallback || accountIsAuthoritative, - let email = account.email, !email.isEmpty + if input.metadata.usesAccountFallback || input.accountIsAuthoritative, + let email = input.account.email, !email.isEmpty { return email } @@ -1242,12 +1247,7 @@ extension UsageMenuCardView.Model { subtitle: (text: String, style: SubtitleStyle)) -> RedactedText { let email = PersonalInfoRedactor.redactEmail( - Self.email( - for: input.provider, - snapshot: input.snapshot, - account: input.account, - metadata: input.metadata, - accountIsAuthoritative: input.accountIsAuthoritative), + Self.email(from: input), isEnabled: input.hidePersonalInfo) let subtitleText = PersonalInfoRedactor.redactEmails(in: subtitle.text, isEnabled: input.hidePersonalInfo) ?? subtitle.text @@ -1403,6 +1403,10 @@ extension UsageMenuCardView.Model { Self.applyPrimaryPacePresentation(&presentation, input: input, primary: primary) } Self.applyPrimaryFinalOverrides(&presentation, input: input, primary: primary) + // Provider-specific by design: z.ai's textual 5-hour reset phrase is provider-owned copy localized here. + if input.provider == .zai, let resetText = Self.localizedZaiPeriodicResetText(primary) { + presentation.resetText = resetText + } if let bindingProjection { let resetWindow = RateWindow( usedPercent: bindingProjection.usedPercent, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 591a59f43..e5abd56ca 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -698,7 +698,7 @@ struct MenuDescriptor { snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) { if provider == .factory, snapshot.tertiary != nil { - return ("5-hour", L("Weekly"), L("Monthly"), true) + return (L("5-hour"), L("Weekly"), L("Monthly"), true) } let cursorLabels = provider == .cursor ? Self.cursorRateWindowLabels( diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 8d1eca3a0..b4af4d6d1 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -223,7 +223,10 @@ struct PlanUtilizationHistoryChartMenuView: View { .map { history in VisibleSeries( selection: SeriesSelection(name: history.name, windowMinutes: history.windowMinutes), - title: self.seriesTitle(name: history.name, metadata: metadata), + title: self.seriesTitle( + name: history.name, + metadata: metadata, + windowMinutes: history.windowMinutes), history: history) } } @@ -621,11 +624,12 @@ struct PlanUtilizationHistoryChartMenuView: View { private nonisolated static func seriesTitle( name: PlanUtilizationSeriesName, - metadata: ProviderMetadata?) -> String + metadata: ProviderMetadata?, + windowMinutes: Int) -> String { switch name { case .session: - L(metadata?.sessionLabel ?? "Session") + localizedSessionQuotaLabel(metadata?.sessionLabel ?? "Session", windowMinutes: windowMinutes) case .weekly: L(metadata?.weeklyLabel ?? "Weekly") case .monthly: @@ -673,6 +677,7 @@ struct PlanUtilizationHistoryChartMenuView: View { let xDomain: ClosedRange? let selectedSeries: String? let visibleSeries: [String] + let visibleSeriesTitles: [String] let usedPercents: [Double] let pointDates: [String] } @@ -696,6 +701,7 @@ struct PlanUtilizationHistoryChartMenuView: View { xDomain: model.xDomain, selectedSeries: selectedSeries?.id, visibleSeries: visibleSeries.map(\.id), + visibleSeriesTitles: visibleSeries.map(\.title), usedPercents: model.points.map(\.usedPercent), pointDates: model.points.map { point in let formatter = DateFormatter() diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index fae447a6e..a58b097b8 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -227,18 +227,12 @@ func spendDashboardModelHistoryPresentation( struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore - @State private var controller: SpendDashboardController @State private var modelMetric: SpendDashboardModelMetric = .cost @State private var isVisible = false init(settings: SettingsStore, store: UsageStore) { self.settings = settings self.store = store - self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }, cachedLoader: { request in - await SpendDashboardSource.loadCached(request) - })) } var body: some View { @@ -280,7 +274,6 @@ struct SpendDashboardPane: View { } .onDisappear { self.isVisible = false - self.controller.stop() } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in self.controller.refreshDateWindow() @@ -300,6 +293,10 @@ struct SpendDashboardPane: View { SpendDashboardSource.configuration(settings: self.settings, store: self.store) } + private var controller: SpendDashboardController { + self.store.sharedSpendDashboardController() + } + private var header: some View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 4) { diff --git a/Sources/CodexBar/Providers/Claude/StatusItemController+ClaudeSwapMenu.swift b/Sources/CodexBar/Providers/Claude/StatusItemController+ClaudeSwapMenu.swift index 33f1fbff4..9f1bab0df 100644 --- a/Sources/CodexBar/Providers/Claude/StatusItemController+ClaudeSwapMenu.swift +++ b/Sources/CodexBar/Providers/Claude/StatusItemController+ClaudeSwapMenu.swift @@ -64,7 +64,8 @@ extension StatusItemController { accountOverride: AccountInfo( email: account.displayLabel, plan: nil), - planOverride: self.claudeSwapAccountActionLabel(account)) + planOverride: self.claudeSwapAccountActionLabel(account), + sourceLabelOverride: ClaudeSwapAccountProjection.sourceLabel) } private func claudeSwapAccountActionLabel(_ account: ProviderAccountUsageSnapshot) -> String? { diff --git a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift index 2df405b0a..51e1c8f49 100644 --- a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift +++ b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift @@ -26,13 +26,24 @@ extension UsageStore { /// windows). Returns nil — keep the ambient snapshot — when the adapter is below /// its presentation threshold or the active account reports no usable usage. func claudeSwapMenuBarSnapshotOverride(for instanceID: ProviderInstanceID) -> UsageSnapshot? { + self.claudeSwapActiveAccountOverride(for: instanceID)?.snapshot + } + + /// Returns the same opaque active account record used by every compact Claude surface. + /// The record lets widgets bind preserved quota to a stable source slot without exposing identity labels. + func claudeSwapActiveAccountOverride( + for instanceID: ProviderInstanceID) -> ProviderAccountUsageSnapshot? + { guard instanceID == UsageProvider.claude.instanceID else { return nil } guard ClaudeSwapMenuPrecedence.prefersClaudeSwap( provider: .claude, accountCount: self.claudeSwapAccountSnapshots.count, showSingleAccount: self.settings.claudeSwapShowSingleAccount) else { return nil } - return self.claudeSwapAccountSnapshots.first(where: \.isActive)?.snapshot + guard let active = self.claudeSwapAccountSnapshots.first(where: \.isActive), active.snapshot != nil else { + return nil + } + return active } func clearClaudeSwapAccountState() { @@ -51,6 +62,7 @@ extension UsageStore { self.claudeSwapTransientState.switchingAccountID = nil if hadState { self.claudeSwapRevision &+= 1 + self.persistWidgetSnapshot(reason: "claude-swap-clear") } } @@ -76,14 +88,19 @@ extension UsageStore { do { let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath) - let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list) + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: ClaudeSwapRetainedUsageStore.previousAccounts( + inMemory: self.claudeSwapAccountSnapshots)) guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else { return } + ClaudeSwapRetainedUsageStore.save(snapshots) self.claudeSwapAccountSnapshots = snapshots self.claudeSwapLastRefreshAt = Date() self.claudeSwapLastError = nil self.claudeSwapRevision &+= 1 + self.persistWidgetSnapshot(reason: "claude-swap-refresh") } catch is CancellationError { return } catch { diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index e56e039e6..a579692fe 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -49,6 +49,7 @@ struct CodexProviderImplementation: ProviderImplementation { func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { switch context.settings.codexUsageDataSource { case .auto: .auto + case .pat: .api case .oauth: .oauth case .cli: .cli } diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 261404f6f..db427d879 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -152,6 +152,7 @@ extension SettingsStore { set { let source: ProviderSourceMode? = switch newValue { case .auto: .auto + case .pat: .api case .oauth: .oauth case .cli: .cli } @@ -703,8 +704,10 @@ extension SettingsStore { private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { guard let source else { return .auto } switch source { - case .auto, .web, .api: + case .auto, .web: return .auto + case .api: + return .pat case .cli: return .cli case .oauth: diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index fc6c23c66..8a9e82eae 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -179,6 +179,19 @@ extension UsageStore { authFingerprint: self.currentCodexAuthFingerprint(source: resolvedSource)) } + /// PAT whoami is the credential's authoritative identity. Do not mix it with an OAuth runtime + /// identity that may be active in the same Codex home or selected managed account. + func seedCodexPATRefreshGuard(source: CodexActiveSource, accountEmail: String?) { + let accountKey = Self.normalizeCodexAccountScopedEmail(accountEmail) + let identity = CodexIdentityResolver.resolve(accountId: nil, email: accountKey) + guard identity != .unresolved || accountKey != nil else { return } + self.lastCodexAccountScopedRefreshGuard = CodexAccountScopedRefreshGuard( + source: source, + identity: identity, + accountKey: accountKey, + authFingerprint: self.currentCodexAuthFingerprint(source: source)) + } + func currentCodexAccountScopedRefreshGuard( preferCurrentSnapshot: Bool = true, allowLastKnownLiveFallback: Bool = true) -> CodexAccountScopedRefreshGuard diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexPATRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexPATRefresh.swift new file mode 100644 index 000000000..376b40ff4 --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexPATRefresh.swift @@ -0,0 +1,146 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + func shouldUseAmbientCodexPATForUsage() -> Bool { + switch self.settings.codexUsageDataSource { + case .pat: + true + case .auto: + (try? CodexOAuthCredentialsStore.loadPATResolvingScopedHome(env: self.codexFetchEnvironment())) + != nil + case .oauth, .cli: + false + } + } + + func codexFetchEnvironment() -> [String: String] { + // Provider-specific by design: PAT admission reads the selected Codex CODEX_HOME fetch environment. + ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: .codex, + settings: self.settings, + tokenOverride: nil) + } + + nonisolated static func isCodexPATOutcome(_ outcome: ProviderFetchOutcome) -> Bool { + guard case let .success(result) = outcome.result else { return false } + return result.strategyID == "codex.pat" || result.sourceLabel == "pat" + } + + nonisolated static func codexPublicationRefreshOverrides( + provider: UsageProvider, + outcome: ProviderFetchOutcome, + explicitPAT: Bool, + expectedGuard: CodexAccountScopedRefreshGuard?, + limitResetOwnerKey: CodexLimitResetOwnerKey?) -> ( + CodexAccountScopedRefreshGuard?, + CodexLimitResetOwnerKey?) + { + let publishesPAT = provider == .codex && self.isCodexPATOutcome(outcome) + let explicitPATFailure = explicitPAT && { + if case .failure = outcome.result { + return true + } + return false + }() + if publishesPAT || explicitPATFailure { + return (nil, publishesPAT ? nil : limitResetOwnerKey) + } + return (expectedGuard, limitResetOwnerKey) + } + + func recordCodexRefreshSuccessPublication( + scoped: UsageSnapshot, + backfilled: UsageSnapshot, + result: ProviderFetchResult, + expectedGuard: CodexAccountScopedRefreshGuard?, + expectedOwnerKey: CodexLimitResetOwnerKey?) + { + self.rememberLiveSystemCodexEmailIfNeeded(scoped.accountEmail(for: .codex)) + let publishesPAT = result.strategyID == "codex.pat" || result.sourceLabel == "pat" + if publishesPAT { + let publicationSource: CodexActiveSource = switch result.codexPATCredentialOwner { + case let .scopedCodexHome(path): + self.codexPATSource(forCredentialHome: path) + default: + .liveSystem + } + self.seedCodexPATRefreshGuard( + source: publicationSource, + accountEmail: scoped.accountEmail(for: .codex)) + } else { + self.seedCodexAccountScopedRefreshGuard(accountEmail: scoped.accountEmail(for: .codex)) + } + self.lastCodexUsagePublicationGuard = self.lastCodexAccountScopedRefreshGuard + self.persistSingleCodexAccountSnapshot( + backfilled, + sourceLabel: result.sourceLabel, + expectedGuard: expectedGuard, + expectedOwnerKey: expectedOwnerKey) + } + + private func codexPATSource(forCredentialHome path: String) -> CodexActiveSource { + let credentialHome = URL(fileURLWithPath: path).standardizedFileURL.path + if let ambientHome = self.environmentBase["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !ambientHome.isEmpty + { + let ambientCodexHome = URL(fileURLWithPath: ambientHome, isDirectory: true) + .appendingPathComponent(".codex", isDirectory: true) + .standardizedFileURL.path + if credentialHome == ambientCodexHome { + return .liveSystem + } + } + return .profileHome(path: credentialHome) + } + + private func persistSingleCodexAccountSnapshot( + _ snapshot: UsageSnapshot, + sourceLabel: String, + expectedGuard: CodexAccountScopedRefreshGuard?, + expectedOwnerKey: CodexLimitResetOwnerKey?) + { + guard let expectedGuard, + let expectedOwnerKey + else { return } + + let currentGuard = self.freshCodexAccountScopedRefreshGuard() + guard Self.codexScopedRefreshGuardsMatchAccount(expectedGuard, currentGuard), + let currentOwnerKey = CodexLimitResetOwnerKey( + identity: currentGuard.identity, + accountEmail: currentGuard.accountKey), + currentOwnerKey == expectedOwnerKey + else { return } + + let visibleAccounts = self.freshCodexVisibleAccountsForSnapshotHydration() + let activeMatches = visibleAccounts.filter { + $0.isActive && + $0.selectionSource == currentGuard.source && + CodexIdentityResolver.normalizeEmail($0.email) == currentGuard.accountKey + } + guard activeMatches.count == 1, + let account = activeMatches.first, + let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)), + snapshotEmail == CodexIdentityResolver.normalizeEmail(currentGuard.accountKey), + snapshotEmail == CodexIdentityResolver.normalizeEmail(account.email), + self.codexLimitResetOwnerKey( + forVisibleAccount: account, + visibleAccounts: visibleAccounts) == currentOwnerKey + else { return } + + let identity = snapshot.identity(for: .codex) + let relabeled = snapshot.withIdentity(ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account.email, + accountOrganization: identity?.accountOrganization, + loginMethod: identity?.loginMethod ?? account.workspaceLabel)) + let currentSnapshots = [CodexAccountUsageSnapshot( + account: account, + snapshot: relabeled, + error: nil, + sourceLabel: sourceLabel)] + self.codexAccountSnapshots = currentSnapshots + self.codexAccountUsageSnapshotStore?.store(currentSnapshots) + } +} diff --git a/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift b/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift index 3e58da323..00740c154 100644 --- a/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift @@ -26,9 +26,7 @@ struct FireworksProviderImplementation: ProviderImplementation { @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { - if FireworksSettingsReader.apiKey(environment: context.environment) != nil, - FireworksSettingsReader.accountSlug(environment: context.environment) != nil - { + if FireworksSettingsReader.apiKey(environment: context.environment) != nil { return true } return context.settings.hasFireworksCredentials @@ -50,21 +48,20 @@ struct FireworksProviderImplementation: ProviderImplementation { ProviderSettingsFieldDescriptor( id: "fireworks-account-slug", title: "Account slug", - subtitle: "The segment after /accounts/ in your app.fireworks.ai URLs, e.g. x0mh0x for " - + "app.fireworks.ai/accounts/x0mh0x. Required because Fireworks has no whoami endpoint.", + subtitle: "Optional when the API key can access one account; QuotaKit discovers it automatically. " + + "For multiple accounts, find the slug in the app.fireworks.ai home account switcher or run " + + "firectl whoami.", kind: .plain, placeholder: "x0mh0x", binding: context.stringBinding(\.fireworksAccountSlug), actions: [ ProviderSettingsActionDescriptor( id: "fireworks-open-billing", - title: "Open Fireworks billing", + title: "Open Fireworks", style: .link, isVisible: nil, perform: { - NSWorkspace.shared.open( - FireworksURLs.billing( - accountSlug: context.settings.fireworksAccountSlug)) + NSWorkspace.shared.open(FireworksURLs.home) }), ], isVisible: nil, @@ -74,11 +71,5 @@ struct FireworksProviderImplementation: ProviderImplementation { } enum FireworksURLs { - static func billing(accountSlug: String) -> URL { - let slug = accountSlug.trimmingCharacters(in: .whitespacesAndNewlines) - if slug.isEmpty { - return URL(string: "https://app.fireworks.ai")! - } - return URL(string: "https://app.fireworks.ai/accounts/\(slug)/settings/billing")! - } + static let home = URL(string: "https://app.fireworks.ai")! } diff --git a/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift b/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift index e2f6dba67..32e85e123 100644 --- a/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift +++ b/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift @@ -23,7 +23,19 @@ extension SettingsStore { var hasFireworksCredentials: Bool { guard let config = self.configSnapshot.providerConfig(for: .fireworks) else { return false } - return config.sanitizedAPIKey != nil && config.sanitizedAccountSlug != nil + return config.sanitizedAPIKey != nil + } + + func persistDiscoveredFireworksAccountSlug(_ accountSlug: String?) { + guard let accountSlug, + let normalized = self.normalizedConfigValue(accountSlug), + self.configSnapshot.providerConfig(for: .fireworks)?.sanitizedAccountSlug != normalized + else { return } + // Merge into SettingsStore's current revision so a fetch completion cannot overwrite another + // pending settings mutation with a stale whole-file snapshot. + self.updateProviderConfig(provider: .fireworks, affectsBackgroundWork: false) { entry in + entry.accountSlug = normalized + } } } diff --git a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift index 6fbc8a57a..af49ef927 100644 --- a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift @@ -16,6 +16,7 @@ struct OpenCodeGoProviderImplementation: ProviderImplementation { _ = settings.opencodegoCookieSource _ = settings.opencodegoCookieHeader _ = settings.opencodegoWorkspaceID + _ = settings[providerConfig: .opencodego, field: .apiKey] } @MainActor @@ -87,6 +88,16 @@ struct OpenCodeGoProviderImplementation: ProviderImplementation { @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ + ProviderSettingsFieldDescriptor( + id: "opencodego-api-key", + title: "API key", + subtitle: "Preferred for Go usage limits. Also reads OPENCODE_API_KEY.", + kind: .secure, + placeholder: "OpenCode API key", + binding: context.providerConfigBinding(.apiKey), + actions: [], + isVisible: nil, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "opencodego-workspace-id", title: "Workspace ID", diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 3ea286850..469418b23 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -938,6 +938,7 @@ "Monthly quota" = "شهريا"; "Sonnet" = "السوناتة"; "Overages" = "الزيادات"; +"Overage" = "التجاوز"; "Activity" = "النشاط"; "Copied" = "تم النسخ"; "Copy error" = "خطأ في النسخ."; @@ -948,6 +949,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "تحديثات التوازن في الوقت شبه الحقيقي (حتى تأخير 5 دقائق)"; "Daily billing data finalizes at 07:00 UTC" = "يتم الانتهاء من بيانات الفوترة اليومية في الساعة 07:00 UTC"; "%@ of %@ credits left" = "%@ من %@ اعتمادات متبقية"; +"of %@" = "من %@"; "%@ of %@ bonus credits left" = "%@ من %@ رصيد إضافي متبقي"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ متبقية)"; "%@/%@ left" = "%@/%@ متبقٍ"; @@ -959,6 +961,7 @@ "Full in ~1 regen" = "تجدد كامل ~1"; "Full in ~%.0f regens" = "جميع التحديثات ~%.0f"; "Overage usage" = "الاستخدام الزائد"; +"Overage credits left" = "الرصيد الزائد المتبقي"; "Overage cost" = "تكلفة التجاوز"; "credits" = "الاعتمادات"; "Zen balance" = "توازن الزن"; @@ -1506,3 +1509,35 @@ "Copy JSON" = "نسخ JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "شرطي"; +"menu_bar_layout_group_conditionals" = "الشروط"; +"menu_bar_layout_conditional_add" = "شرطي جديد"; +"menu_bar_layout_conditional_edit" = "تحرير الشرطي"; +"menu_bar_layout_conditional_remove" = "إزالة من المكتبة"; +"menu_bar_layout_conditional_save" = "حفظ"; +"menu_bar_layout_conditional_if" = "إذا كان"; +"menu_bar_layout_conditional_then" = "فعندئذ اعرض"; +"menu_bar_layout_conditional_else" = "وإلا اعرض"; +"menu_bar_layout_conditional_and" = "و"; +"menu_bar_layout_conditional_none" = "لا توجد شروط بعد"; +"menu_bar_layout_conditional_hide" = "إخفاء"; +"menu_bar_layout_conditional_name" = "الاسم"; +"menu_bar_layout_conditional_name_placeholder" = "مثال: فحص الجلسة"; +"menu_bar_layout_conditional_name_error" = "أدخل اسماً فريداً"; +"menu_bar_layout_conditional_used" = "مستخدم"; +"menu_bar_layout_conditional_remaining" = "متبقٍ"; +"menu_bar_layout_conditional_metric_resets_in" = "تتم إعادة تعيين %@ خلال"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "أسبوعي محدد النطاق"; +"menu_bar_layout_conditional_duplicate" = "تكرار"; +"menu_bar_layout_conditional_or" = "أو"; +"menu_bar_layout_conditional_add_condition" = "إضافة شرط"; +"menu_bar_layout_conditional_summary" = "إذا كان %1$@ فعندئذ %2$@ وإلا %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (نسخة)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (نسخة %2$d)"; +"menu_bar_layout_conditional_unavailable" = "الشرط غير متاح"; +"menu_bar_layout_conditional_default_session_busy" = "استُخدم أكثر من 50٪ من الجلسة"; +"menu_bar_layout_conditional_default_weekly_high" = "استُخدم أكثر من 90٪ من الأسبوع"; +"menu_bar_layout_conditional_default_session_spent" = "الجلسة على وشك النفاد"; +"menu_bar_layout_conditional_default_either_high" = "الجلسة أو الأسبوع في مستوى مرتفع"; +"menu_bar_layout_conditional_default_scoped_weekly" = "نافذة الطراز الأسبوعية فوق 60٪"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 560498749..d9e198fde 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -801,6 +801,7 @@ "Sonnet" = "Sonnet"; "Auth" = "Autenticació"; "Overages" = "Excedents"; +"Overage" = "Excedent"; "Activity" = "Activitat"; "Copied" = "Copiat"; "Copy error" = "Error en copiar"; @@ -811,6 +812,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "El saldo s'actualitza gairebé en temps real (fins a 5 min de retard)"; "Daily billing data finalizes at 07:00 UTC" = "Les dades diàries de facturació es tanquen a les 07:00 UTC"; "%@ of %@ credits left" = "Queden %@ de %@ crèdits"; +"of %@" = "de %@"; "%@ of %@ bonus credits left" = "Queden %@ de %@ crèdits de bonificació"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restants)"; "%@/%@ left" = "%@/%@ restant"; @@ -822,6 +824,7 @@ "Full in ~1 regen" = "Ple en ~1 regeneració"; "Full in ~%.0f regens" = "Ple en ~%.0f regeneracions"; "Overage usage" = "Ús excedent"; +"Overage credits left" = "Crèdits excedents restants"; "Overage cost" = "Cost excedent"; "credits" = "crèdits"; "Zen balance" = "Saldo Zen"; @@ -1505,3 +1508,35 @@ "Copy JSON" = "Copia JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionals"; +"menu_bar_layout_conditional_add" = "Condicional nou"; +"menu_bar_layout_conditional_edit" = "Edita el condicional"; +"menu_bar_layout_conditional_remove" = "Elimina de la biblioteca"; +"menu_bar_layout_conditional_save" = "Desa"; +"menu_bar_layout_conditional_if" = "Si"; +"menu_bar_layout_conditional_then" = "Mostra llavors"; +"menu_bar_layout_conditional_else" = "Mostra en cas contrari"; +"menu_bar_layout_conditional_and" = "i"; +"menu_bar_layout_conditional_none" = "Encara no hi ha condicionals"; +"menu_bar_layout_conditional_hide" = "Amaga"; +"menu_bar_layout_conditional_name" = "Nom"; +"menu_bar_layout_conditional_name_placeholder" = "p. ex. Comprovació de la sessió"; +"menu_bar_layout_conditional_name_error" = "Introdueix un nom únic"; +"menu_bar_layout_conditional_used" = "utilitzat"; +"menu_bar_layout_conditional_remaining" = "restant"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ es reinicia en"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Setmanal amb àmbit"; +"menu_bar_layout_conditional_duplicate" = "Duplica"; +"menu_bar_layout_conditional_or" = "o"; +"menu_bar_layout_conditional_add_condition" = "Afegeix una condició"; +"menu_bar_layout_conditional_summary" = "Si %1$@ llavors %2$@ en cas contrari %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (còpia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (còpia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional no disponible"; +"menu_bar_layout_conditional_default_session_busy" = "Sessió per damunt del 50 % utilitzat"; +"menu_bar_layout_conditional_default_weekly_high" = "Setmana per damunt del 90 % utilitzat"; +"menu_bar_layout_conditional_default_session_spent" = "Sessió gairebé esgotada"; +"menu_bar_layout_conditional_default_either_high" = "Sessió o setmana en nivell alt"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Setmana per model per damunt del 60 %"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index ee6d698cc..69b240082 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -561,7 +561,7 @@ "tab_menu_bar" = "Menüleiste"; "tab_menu" = "Menü"; "tab_advanced" = "Fortschrittlich"; -"tab_about" = "Um"; +"tab_about" = "Über"; "tab_debug" = "Debuggen"; /* Providers Pane */ @@ -840,7 +840,7 @@ "Status Page" = "Statusseite"; "Open Status Page" = "Statusseite öffnen"; "Settings..." = "Einstellungen..."; -"About CodexBar" = "About QuotaKit"; +"About CodexBar" = "Über QuotaKit"; "Quit" = "Beenden"; "Last %d day" = "Letzter %d Tag"; "Last %d days" = "Letzte %d Tage"; @@ -929,6 +929,7 @@ "Monthly quota" = "Monatlich"; "Sonnet" = "Sonett"; "Overages" = "Überschreitungen"; +"Overage" = "Überschreitung"; "Activity" = "Aktivität"; "Copied" = "Kopiert"; "Copy error" = "Kopierfehler"; @@ -939,6 +940,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Guthabenaktualisierungen nahezu in Echtzeit (bis zu 5 Minuten Verzögerung)"; "Daily billing data finalizes at 07:00 UTC" = "Die täglichen Abrechnungsdaten werden um 07:00 UTC finalisiert"; "%@ of %@ credits left" = "%@ von %@ Credits übrig"; +"of %@" = "von %@"; "%@ of %@ bonus credits left" = "%@ von %@ Bonusguthaben übrig"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ verbleibend)"; "%@/%@ left" = "%@/%@ übrig"; @@ -950,6 +952,7 @@ "Full in ~1 regen" = "Voll in ~1 Regeneration"; "Full in ~%.0f regens" = "Voll in ~%.0f Regenerationen"; "Overage usage" = "Übermäßige Nutzung"; +"Overage credits left" = "Verbleibende Überschreitungs-Credits"; "Overage cost" = "Überschreitungskosten"; "credits" = "Credits"; "Zen balance" = "Zen-Balance"; @@ -1503,3 +1506,34 @@ "Copy JSON" = "JSON kopieren"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Bedingt"; +"menu_bar_layout_group_conditionals" = "Bedingungen"; +"menu_bar_layout_conditional_add" = "Neue Bedingung"; +"menu_bar_layout_conditional_edit" = "Bedingung bearbeiten"; +"menu_bar_layout_conditional_remove" = "Aus der Bibliothek entfernen"; +"menu_bar_layout_conditional_save" = "Speichern"; +"menu_bar_layout_conditional_if" = "Wenn"; +"menu_bar_layout_conditional_then" = "Dann anzeigen"; +"menu_bar_layout_conditional_else" = "Sonst anzeigen"; +"menu_bar_layout_conditional_and" = "und"; +"menu_bar_layout_conditional_or" = "oder"; +"menu_bar_layout_conditional_add_condition" = "Bedingung hinzufügen"; +"menu_bar_layout_conditional_none" = "Noch keine Bedingungen"; +"menu_bar_layout_conditional_hide" = "Ausblenden"; +"menu_bar_layout_conditional_name" = "Name"; +"menu_bar_layout_conditional_name_placeholder" = "z. B. Sitzungsprüfung"; +"menu_bar_layout_conditional_name_error" = "Geben Sie einen eindeutigen Namen ein"; +"menu_bar_layout_conditional_used" = "genutzt"; +"menu_bar_layout_conditional_remaining" = "übrig"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ zurückgesetzt in"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Wöchentlich nach Bereich"; +"menu_bar_layout_conditional_duplicate" = "Duplizieren"; +"menu_bar_layout_conditional_summary" = "Wenn %1$@ dann %2$@ sonst %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (Kopie)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (Kopie %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Bedingung nicht verfügbar"; +"menu_bar_layout_conditional_default_session_busy" = "Sitzung über 50 % genutzt"; +"menu_bar_layout_conditional_default_weekly_high" = "Woche über 90 % genutzt"; +"menu_bar_layout_conditional_default_session_spent" = "Sitzung fast aufgebraucht"; +"menu_bar_layout_conditional_default_either_high" = "Sitzung oder Woche läuft hoch"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Modellfenster über 60 % genutzt"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index aa9b58dfe..fb4640a07 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -913,6 +913,7 @@ "Monthly quota" = "Monthly quota"; "Sonnet" = "Sonnet"; "Overages" = "Overages"; +"Overage" = "Overage"; "Activity" = "Activity"; "Copied" = "Copied"; "Copy error" = "Copy error"; @@ -923,6 +924,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Balance updates in near-real time (up to 5 min lag)"; "Daily billing data finalizes at 07:00 UTC" = "Daily billing data finalizes at 07:00 UTC"; "%@ of %@ credits left" = "%@ of %@ credits left"; +"of %@" = "of %@"; "%@ of %@ bonus credits left" = "%@ of %@ bonus credits left"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ remaining)"; "%@/%@ left" = "%@/%@ left"; @@ -934,6 +936,7 @@ "Full in ~1 regen" = "Full in ~1 regen"; "Full in ~%.0f regens" = "Full in ~%.0f regens"; "Overage usage" = "Overage usage"; +"Overage credits left" = "Overage credits left"; "Overage cost" = "Overage cost"; "credits" = "credits"; "Zen balance" = "Zen balance"; @@ -1506,3 +1509,34 @@ "Fast" = "Fast"; "claude_oauth_keychain_access_revoked" = "Claude Keychain access was revoked by Claude Code's token rotation. Click Refresh to re-grant access, or switch Claude Usage source to CLI/Web."; "claude_showing_last_known_usage" = "Showing last-known usage captured %@."; +"menu_bar_layout_token_conditional" = "Conditional"; +"menu_bar_layout_group_conditionals" = "Conditionals"; +"menu_bar_layout_conditional_add" = "New Conditional"; +"menu_bar_layout_conditional_edit" = "Edit Conditional"; +"menu_bar_layout_conditional_remove" = "Remove from Library"; +"menu_bar_layout_conditional_save" = "Save"; +"menu_bar_layout_conditional_if" = "If"; +"menu_bar_layout_conditional_then" = "Then show"; +"menu_bar_layout_conditional_else" = "Else show"; +"menu_bar_layout_conditional_and" = "and"; +"menu_bar_layout_conditional_or" = "or"; +"menu_bar_layout_conditional_add_condition" = "Add Condition"; +"menu_bar_layout_conditional_none" = "No conditionals yet"; +"menu_bar_layout_conditional_hide" = "Hide"; +"menu_bar_layout_conditional_duplicate" = "Duplicate"; +"menu_bar_layout_conditional_name" = "Name"; +"menu_bar_layout_conditional_name_placeholder" = "e.g. Session check"; +"menu_bar_layout_conditional_name_error" = "Enter a unique name"; +"menu_bar_layout_conditional_used" = "used"; +"menu_bar_layout_conditional_remaining" = "remaining"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ resets in"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Scoped weekly"; +"menu_bar_layout_conditional_summary" = "If %1$@ then %2$@ else %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copy)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copy %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Conditional unavailable"; +"menu_bar_layout_conditional_default_session_busy" = "Session over 50% used"; +"menu_bar_layout_conditional_default_weekly_high" = "Weekly over 90% used"; +"menu_bar_layout_conditional_default_session_spent" = "Session nearly spent"; +"menu_bar_layout_conditional_default_either_high" = "Session or weekly running high"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Scoped weekly over 60% used"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 58644f802..09cb6019a 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -810,6 +810,7 @@ "Sonnet" = "Sonnet"; "Auth" = "Autenticación"; "Overages" = "Excesos"; +"Overage" = "Exceso"; "Activity" = "Actividad"; "Copied" = "Copiado"; "Copy error" = "Error al copiar"; @@ -820,6 +821,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "El saldo se actualiza casi en tiempo real (hasta 5 min de retraso)"; "Daily billing data finalizes at 07:00 UTC" = "Los datos diarios de facturación se cierran a las 07:00 UTC"; "%@ of %@ credits left" = "Quedan %@ de %@ créditos"; +"of %@" = "de %@"; "%@ of %@ bonus credits left" = "Quedan %@ de %@ créditos de bonificación"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restante)"; "%@/%@ left" = "%@/%@ restante"; @@ -831,6 +833,7 @@ "Full in ~1 regen" = "Lleno en ~1 regeneración"; "Full in ~%.0f regens" = "Lleno en ~%.0f regeneraciones"; "Overage usage" = "Uso excedente"; +"Overage credits left" = "Créditos excedentes restantes"; "Overage cost" = "Coste excedente"; "credits" = "créditos"; "Zen balance" = "Saldo Zen"; @@ -1501,3 +1504,34 @@ "Copy JSON" = "Copiar JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionales"; +"menu_bar_layout_conditional_add" = "Nueva condición"; +"menu_bar_layout_conditional_edit" = "Editar condición"; +"menu_bar_layout_conditional_remove" = "Quitar de la biblioteca"; +"menu_bar_layout_conditional_save" = "Guardar"; +"menu_bar_layout_conditional_if" = "Si"; +"menu_bar_layout_conditional_then" = "Mostrar entonces"; +"menu_bar_layout_conditional_else" = "Si no, mostrar"; +"menu_bar_layout_conditional_and" = "y"; +"menu_bar_layout_conditional_or" = "o"; +"menu_bar_layout_conditional_add_condition" = "Añadir condición"; +"menu_bar_layout_conditional_none" = "Aún no hay condiciones"; +"menu_bar_layout_conditional_hide" = "Ocultar"; +"menu_bar_layout_conditional_name" = "Nombre"; +"menu_bar_layout_conditional_name_placeholder" = "p. ej. Comprobación de sesión"; +"menu_bar_layout_conditional_name_error" = "Introduce un nombre único"; +"menu_bar_layout_conditional_used" = "usado"; +"menu_bar_layout_conditional_remaining" = "restante"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ se reinicia en"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Semanal con ámbito"; +"menu_bar_layout_conditional_duplicate" = "Duplicar"; +"menu_bar_layout_conditional_summary" = "Si %1$@ entonces %2$@ si no %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional no disponible"; +"menu_bar_layout_conditional_default_session_busy" = "Sesión por encima del 50 % usado"; +"menu_bar_layout_conditional_default_weekly_high" = "Semana por encima del 90 % usado"; +"menu_bar_layout_conditional_default_session_spent" = "Sesión casi agotada"; +"menu_bar_layout_conditional_default_either_high" = "Sesión o semana en nivel alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semana por modelo por encima del 60 %"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index e63522dfc..3745bb3e7 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -938,6 +938,7 @@ "Monthly quota" = "ماهانه"; "Sonnet" = "سونت"; "Overages" = "اضافه هزینه ها"; +"Overage" = "اضافه هزینه"; "Activity" = "فعالیت ها"; "Copied" = "کپی شده"; "Copy error" = "خطای کپی"; @@ -948,6 +949,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "به روزرسانی تعادل تقریبا در زمان واقعی (تا ۵ دقیقه تأخیر)"; "Daily billing data finalizes at 07:00 UTC" = "داده های صورتحساب روزانه در ساعت ۰۷:۰۰ نهایی می شود UTC"; "%@ of %@ credits left" = "%@ از %@ اعتبار باقی مانده"; +"of %@" = "از %@"; "%@ of %@ bonus credits left" = "%@ از %@ اعتبار اضافی باقی مانده"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ باقی مانده)"; "%@/%@ left" = "%@/%@ باقی مانده"; @@ -959,6 +961,7 @@ "Full in ~1 regen" = "بازیابی کامل ~۱"; "Full in ~%.0f regens" = "بازسازی های کامل ~%.0f"; "Overage usage" = "استفاده بیش از حد"; +"Overage credits left" = "اعتبار اضافی باقی‌مانده"; "Overage cost" = "هزینه اضافی"; "credits" = "اعتبارات"; "Zen balance" = "تعادل ذن"; @@ -1506,3 +1509,35 @@ "Copy JSON" = "کپی JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "شرطی"; +"menu_bar_layout_group_conditionals" = "شروط"; +"menu_bar_layout_conditional_add" = "شرطی جدید"; +"menu_bar_layout_conditional_edit" = "ویرایش شرطی"; +"menu_bar_layout_conditional_remove" = "حذف از کتابخانه"; +"menu_bar_layout_conditional_save" = "ذخیره"; +"menu_bar_layout_conditional_if" = "اگر"; +"menu_bar_layout_conditional_then" = "سپس نمایش بده"; +"menu_bar_layout_conditional_else" = "در غیر این صورت نمایش بده"; +"menu_bar_layout_conditional_and" = "و"; +"menu_bar_layout_conditional_none" = "هنوز شرطی وجود ندارد"; +"menu_bar_layout_conditional_hide" = "پنهان کن"; +"menu_bar_layout_conditional_name" = "نام"; +"menu_bar_layout_conditional_name_placeholder" = "مثلاً: بررسی جلسه"; +"menu_bar_layout_conditional_name_error" = "نامی منحصربهفرد وارد کنید"; +"menu_bar_layout_conditional_used" = "استفاده‌شده"; +"menu_bar_layout_conditional_remaining" = "باقی‌مانده"; +"menu_bar_layout_conditional_metric_resets_in" = "بازنشانی %@ در"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "هفتگی با دامنه"; +"menu_bar_layout_conditional_duplicate" = "تکرار"; +"menu_bar_layout_conditional_or" = "یا"; +"menu_bar_layout_conditional_add_condition" = "افزودن شرط"; +"menu_bar_layout_conditional_summary" = "اگر %1$@ سپس %2$@ در غیر این صورت %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (رونوشت)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (رونوشت %2$d)"; +"menu_bar_layout_conditional_unavailable" = "شرط در دسترس نیست"; +"menu_bar_layout_conditional_default_session_busy" = "بیش از ۵۰٪ نشست مصرف شده"; +"menu_bar_layout_conditional_default_weekly_high" = "بیش از ۹۰٪ هفته مصرف شده"; +"menu_bar_layout_conditional_default_session_spent" = "نشست تقریباً تمام شده"; +"menu_bar_layout_conditional_default_either_high" = "نشست یا هفته در سطح بالا"; +"menu_bar_layout_conditional_default_scoped_weekly" = "پنجره هفتگی مدل بیش از ۶۰٪"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ada5c6736..fcf1ca828 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -931,6 +931,7 @@ "Monthly quota" = "Mensuel"; "Sonnet" = "Sonnet"; "Overages" = "Dépassements"; +"Overage" = "Dépassement"; "Activity" = "Activité"; "Copied" = "Copié"; "Copy error" = "Erreur de copie"; @@ -941,6 +942,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Mises à jour du solde en temps quasi réel (jusqu'à 5 minutes de décalage)"; "Daily billing data finalizes at 07:00 UTC" = "Les données de facturation quotidiennes se terminent à 07h00 UTC"; "%@ of %@ credits left" = "%@ sur %@ crédits restants"; +"of %@" = "sur %@"; "%@ of %@ bonus credits left" = "%@ de %@ crédits bonus restants"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restant)"; "%@/%@ left" = "%@/%@ gauche"; @@ -952,6 +954,7 @@ "Full in ~1 regen" = "Complet en ~1 régénération"; "Full in ~%.0f regens" = "Plein en ~%.0f régénérations"; "Overage usage" = "Utilisation excédentaire"; +"Overage credits left" = "Crédits de dépassement restants"; "Overage cost" = "Coût excédentaire"; "credits" = "crédits"; "Zen balance" = "L'équilibre zen"; @@ -1502,3 +1505,34 @@ "Copy JSON" = "Copier le JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Conditionnel"; +"menu_bar_layout_group_conditionals" = "Conditionnels"; +"menu_bar_layout_conditional_add" = "Nouveau conditionnel"; +"menu_bar_layout_conditional_edit" = "Modifier le conditionnel"; +"menu_bar_layout_conditional_remove" = "Retirer de la bibliothèque"; +"menu_bar_layout_conditional_save" = "Enregistrer"; +"menu_bar_layout_conditional_if" = "Si"; +"menu_bar_layout_conditional_then" = "Alors afficher"; +"menu_bar_layout_conditional_else" = "Sinon afficher"; +"menu_bar_layout_conditional_and" = "et"; +"menu_bar_layout_conditional_or" = "ou"; +"menu_bar_layout_conditional_add_condition" = "Ajouter une condition"; +"menu_bar_layout_conditional_none" = "Aucun conditionnel pour l\u2019instant"; +"menu_bar_layout_conditional_hide" = "Masquer"; +"menu_bar_layout_conditional_name" = "Nom"; +"menu_bar_layout_conditional_name_placeholder" = "p. ex. Vérification de session"; +"menu_bar_layout_conditional_name_error" = "Saisissez un nom unique"; +"menu_bar_layout_conditional_used" = "utilisé"; +"menu_bar_layout_conditional_remaining" = "restant"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ réinitialisé dans"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Hebdomadaire ciblé"; +"menu_bar_layout_conditional_duplicate" = "Dupliquer"; +"menu_bar_layout_conditional_summary" = "Si %1$@ alors %2$@ sinon %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copie)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copie %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condition indisponible"; +"menu_bar_layout_conditional_default_session_busy" = "Session au-delà de 50 % utilisés"; +"menu_bar_layout_conditional_default_weekly_high" = "Semaine au-delà de 90 % utilisés"; +"menu_bar_layout_conditional_default_session_spent" = "Session presque épuisée"; +"menu_bar_layout_conditional_default_either_high" = "Session ou semaine à un niveau élevé"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semaine par modèle au-delà de 60 %"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 7b22b1747..ac254d638 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -790,6 +790,7 @@ "Sonnet" = "Sonnet"; "Auth" = "Autenticación"; "Overages" = "Excesos"; +"Overage" = "Exceso"; "Activity" = "Actividade"; "Copied" = "Copiado"; "Copy error" = "Erro ao copiar"; @@ -800,6 +801,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "O saldo actualízase case en tempo real (cun atraso de ata 5 min)"; "Daily billing data finalizes at 07:00 UTC" = "Os datos diarios de facturación péchanse ás 07:00 UTC"; "%@ of %@ credits left" = "Quedan %@ de %@ créditos"; +"of %@" = "de %@"; "%@ of %@ bonus credits left" = "Quedan %@ de %@ créditos extra"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restantes)"; "%@/%@ left" = "Quedan %@/%@"; @@ -811,6 +813,7 @@ "Full in ~1 regen" = "Cheo en ~1 rexeneración"; "Full in ~%.0f regens" = "Cheo en ~%.0f rexeneracións"; "Overage usage" = "Uso excesivo"; +"Overage credits left" = "Créditos de exceso restantes"; "Overage cost" = "Custo excedente"; "credits" = "créditos"; "Zen balance" = "Saldo Zen"; @@ -1502,3 +1505,34 @@ "Copy JSON" = "Copiar JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionais"; +"menu_bar_layout_conditional_add" = "Nova condición"; +"menu_bar_layout_conditional_edit" = "Editar a condición"; +"menu_bar_layout_conditional_remove" = "Retirar da biblioteca"; +"menu_bar_layout_conditional_save" = "Gardar"; +"menu_bar_layout_conditional_if" = "Se"; +"menu_bar_layout_conditional_then" = "Entón mostrar"; +"menu_bar_layout_conditional_else" = "Se non, mostrar"; +"menu_bar_layout_conditional_and" = "e"; +"menu_bar_layout_conditional_or" = "ou"; +"menu_bar_layout_conditional_add_condition" = "Engadir condición"; +"menu_bar_layout_conditional_none" = "Aínda non hai condicións"; +"menu_bar_layout_conditional_hide" = "Agochar"; +"menu_bar_layout_conditional_name" = "Nome"; +"menu_bar_layout_conditional_name_placeholder" = "p. ex. Comprobación de sesión"; +"menu_bar_layout_conditional_name_error" = "Introduce un nome único"; +"menu_bar_layout_conditional_used" = "usado"; +"menu_bar_layout_conditional_remaining" = "restante"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ reiníciase en"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Semanal con ámbito"; +"menu_bar_layout_conditional_duplicate" = "Duplicar"; +"menu_bar_layout_conditional_summary" = "Se %1$@ entón %2$@ se non %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional non dispoñible"; +"menu_bar_layout_conditional_default_session_busy" = "Sesión por riba do 50 % usado"; +"menu_bar_layout_conditional_default_weekly_high" = "Semana por riba do 90 % usado"; +"menu_bar_layout_conditional_default_session_spent" = "Sesión case esgotada"; +"menu_bar_layout_conditional_default_either_high" = "Sesión ou semana en nivel alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semana por modelo por riba do 60 %"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 0f662c31c..287f6fad1 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -940,6 +940,7 @@ "Monthly quota" = "Bulanan"; "Sonnet" = "Sonnet"; "Overages" = "Kelebihan"; +"Overage" = "Kelebihan"; "Activity" = "Aktivitas"; "Copied" = "Disalin"; "Copy error" = "Salin error"; @@ -950,6 +951,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Saldo diperbarui hampir real-time (hingga 5 menit keterlambatan)"; "Daily billing data finalizes at 07:00 UTC" = "Data tagihan harian final pada 07:00 UTC"; "%@ of %@ credits left" = "%@ dari %@ kredit tersisa"; +"of %@" = "dari %@"; "%@ of %@ bonus credits left" = "%@ dari %@ kredit bonus tersisa"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ tersisa)"; "%@/%@ left" = "%@/%@ tersisa"; @@ -961,6 +963,7 @@ "Full in ~1 regen" = "Penuh dalam ~1 regenerasi"; "Full in ~%.0f regens" = "Penuh dalam ~%.0f regenerasi"; "Overage usage" = "Penggunaan kelebihan"; +"Overage credits left" = "Sisa kredit kelebihan"; "Overage cost" = "Biaya kelebihan"; "credits" = "kredit"; "Zen balance" = "Saldo Zen"; @@ -1506,3 +1509,34 @@ "Copy JSON" = "Salin JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Bersyarat"; +"menu_bar_layout_group_conditionals" = "Kondisional"; +"menu_bar_layout_conditional_add" = "Kondisional Baru"; +"menu_bar_layout_conditional_edit" = "Edit Kondisional"; +"menu_bar_layout_conditional_remove" = "Hapus dari Perpustakaan"; +"menu_bar_layout_conditional_save" = "Simpan"; +"menu_bar_layout_conditional_if" = "Jika"; +"menu_bar_layout_conditional_then" = "Lalu tampilkan"; +"menu_bar_layout_conditional_else" = "Jika tidak, tampilkan"; +"menu_bar_layout_conditional_and" = "dan"; +"menu_bar_layout_conditional_none" = "Belum ada kondisional"; +"menu_bar_layout_conditional_hide" = "Sembunyikan"; +"menu_bar_layout_conditional_name" = "Nama"; +"menu_bar_layout_conditional_name_placeholder" = "mis. Pemeriksaan sesi"; +"menu_bar_layout_conditional_name_error" = "Masukkan nama yang unik"; +"menu_bar_layout_conditional_used" = "terpakai"; +"menu_bar_layout_conditional_remaining" = "tersisa"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ disetel ulang dalam"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Mingguan terbatas"; +"menu_bar_layout_conditional_duplicate" = "Duplikat"; +"menu_bar_layout_conditional_or" = "atau"; +"menu_bar_layout_conditional_add_condition" = "Tambah Kondisi"; +"menu_bar_layout_conditional_summary" = "Jika %1$@ maka %2$@, jika tidak %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (salinan)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (salinan %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Kondisional tidak tersedia"; +"menu_bar_layout_conditional_default_session_busy" = "Sesi di atas 50% terpakai"; +"menu_bar_layout_conditional_default_weekly_high" = "Mingguan di atas 90% terpakai"; +"menu_bar_layout_conditional_default_session_spent" = "Sesi hampir habis"; +"menu_bar_layout_conditional_default_either_high" = "Sesi atau mingguan sedang tinggi"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Mingguan per model di atas 60%"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 2555b367a..06dd19335 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -940,6 +940,7 @@ "Monthly quota" = "Mensile"; "Sonnet" = "Claude Sonnet"; "Overages" = "Eccedenze"; +"Overage" = "Eccedenza"; "Activity" = "Attività"; "Copied" = "Copiato"; "Copy error" = "Errore di copia"; @@ -950,6 +951,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Il saldo si aggiorna quasi in tempo reale (ritardo fino a 5 min)"; "Daily billing data finalizes at 07:00 UTC" = "I dati giornalieri di fatturazione si consolidano alle 07:00 UTC"; "%@ of %@ credits left" = "%@ di %@ crediti rimasti"; +"of %@" = "di %@"; "%@ of %@ bonus credits left" = "%@ di %@ crediti bonus rimasti"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ rimanenti)"; "%@/%@ left" = "%@/%@ rimasti"; @@ -961,6 +963,7 @@ "Full in ~1 regen" = "Pieno tra ~1 rigenerazione"; "Full in ~%.0f regens" = "Pieno tra ~%.0f rigenerazioni"; "Overage usage" = "Utilizzo in eccedenza"; +"Overage credits left" = "Crediti in eccedenza rimanenti"; "Overage cost" = "Costo eccedenza"; "credits" = "crediti"; "Zen balance" = "Saldo Zen"; @@ -1506,3 +1509,34 @@ "Copy JSON" = "Copia JSON"; "Sources" = "Origini"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condizionale"; +"menu_bar_layout_group_conditionals" = "Condizionali"; +"menu_bar_layout_conditional_add" = "Nuovo condizionale"; +"menu_bar_layout_conditional_edit" = "Modifica condizionale"; +"menu_bar_layout_conditional_remove" = "Rimuovi dalla libreria"; +"menu_bar_layout_conditional_save" = "Salva"; +"menu_bar_layout_conditional_if" = "Se"; +"menu_bar_layout_conditional_then" = "Allora mostra"; +"menu_bar_layout_conditional_else" = "Altrimenti mostra"; +"menu_bar_layout_conditional_and" = "e"; +"menu_bar_layout_conditional_or" = "o"; +"menu_bar_layout_conditional_add_condition" = "Aggiungi condizione"; +"menu_bar_layout_conditional_none" = "Ancora nessun condizionale"; +"menu_bar_layout_conditional_hide" = "Nascondi"; +"menu_bar_layout_conditional_name" = "Nome"; +"menu_bar_layout_conditional_name_placeholder" = "ad es. Controllo sessione"; +"menu_bar_layout_conditional_name_error" = "Inserisci un nome univoco"; +"menu_bar_layout_conditional_used" = "usato"; +"menu_bar_layout_conditional_remaining" = "rimanente"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ si azzera in"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Settimanale con ambito"; +"menu_bar_layout_conditional_duplicate" = "Duplica"; +"menu_bar_layout_conditional_summary" = "Se %1$@ allora %2$@ altrimenti %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condizionale non disponibile"; +"menu_bar_layout_conditional_default_session_busy" = "Sessione oltre il 50% usato"; +"menu_bar_layout_conditional_default_weekly_high" = "Settimana oltre il 90% usato"; +"menu_bar_layout_conditional_default_session_spent" = "Sessione quasi esaurita"; +"menu_bar_layout_conditional_default_either_high" = "Sessione o settimana a livello alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Settimana per modello oltre il 60%"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index f0e34a04f..5cec97b0d 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -928,6 +928,7 @@ "Monthly quota" = "月間"; "Sonnet" = "Sonnet"; "Overages" = "超過分"; +"Overage" = "超過分"; "Activity" = "アクティビティ"; "Copied" = "コピーしました"; "Copy error" = "エラーをコピー"; @@ -938,6 +939,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "残高はほぼリアルタイムで更新されます(最大5分の遅延)"; "Daily billing data finalizes at 07:00 UTC" = "日次請求データは 07:00 UTC に確定します"; "%@ of %@ credits left" = "クレジット残り %@ / %@"; +"of %@" = "/ %@"; "%@ of %@ bonus credits left" = "ボーナスクレジット残り %@ / %@"; "%@ / %@ (%@ remaining)" = "%@ / %@(残り %@)"; "%@/%@ left" = "残り %@/%@"; @@ -949,6 +951,7 @@ "Full in ~1 regen" = "約1回の再生成で満タン"; "Full in ~%.0f regens" = "約%.0f回の再生成で満タン"; "Overage usage" = "超過使用量"; +"Overage credits left" = "残り超過クレジット"; "Overage cost" = "超過コスト"; "credits" = "クレジット"; "Zen balance" = "Zen 残高"; @@ -1503,3 +1506,34 @@ "Copy JSON" = "JSONをコピー"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "条件付き"; +"menu_bar_layout_group_conditionals" = "条件"; +"menu_bar_layout_conditional_add" = "新しい条件"; +"menu_bar_layout_conditional_edit" = "条件を編集"; +"menu_bar_layout_conditional_remove" = "ライブラリから削除"; +"menu_bar_layout_conditional_save" = "保存"; +"menu_bar_layout_conditional_if" = "もし"; +"menu_bar_layout_conditional_then" = "表示"; +"menu_bar_layout_conditional_else" = "それ以外は表示"; +"menu_bar_layout_conditional_and" = "かつ"; +"menu_bar_layout_conditional_none" = "条件はまだありません"; +"menu_bar_layout_conditional_hide" = "非表示"; +"menu_bar_layout_conditional_name" = "名前"; +"menu_bar_layout_conditional_name_placeholder" = "例:セッションチェック"; +"menu_bar_layout_conditional_name_error" = "一意の名前を入力してください"; +"menu_bar_layout_conditional_used" = "使用"; +"menu_bar_layout_conditional_remaining" = "残り"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ のリセットまで"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "スコープ別週間"; +"menu_bar_layout_conditional_duplicate" = "複製"; +"menu_bar_layout_conditional_or" = "または"; +"menu_bar_layout_conditional_add_condition" = "条件を追加"; +"menu_bar_layout_conditional_summary" = "もし %1$@ なら %2$@ を表示し、それ以外は %3$@ を表示"; +"menu_bar_layout_conditional_copy_name" = "%@(コピー)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@(コピー %2$d)"; +"menu_bar_layout_conditional_unavailable" = "条件を利用できません"; +"menu_bar_layout_conditional_default_session_busy" = "セッション使用率 50% 超"; +"menu_bar_layout_conditional_default_weekly_high" = "週間使用率 90% 超"; +"menu_bar_layout_conditional_default_session_spent" = "セッションがほぼ上限"; +"menu_bar_layout_conditional_default_either_high" = "セッションまたは週間が高水準"; +"menu_bar_layout_conditional_default_scoped_weekly" = "モデル別週間の使用率 60% 超"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 86cd9cfbf..933d76b44 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -900,6 +900,7 @@ "Monthly quota" = "월간"; "Sonnet" = "Sonnet"; "Overages" = "초과분"; +"Overage" = "초과분"; "Activity" = "활동"; "Copied" = "복사됨"; "Copy error" = "오류 복사"; @@ -910,6 +911,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "잔액은 거의 실시간으로 업데이트됩니다(최대 5분 지연)"; "Daily billing data finalizes at 07:00 UTC" = "일간 청구 데이터는 07:00 UTC에 확정됩니다"; "%@ of %@ credits left" = "크레딧 %2$@개 중 %1$@개 남음"; +"of %@" = "/ %@"; "%@ of %@ bonus credits left" = "보너스 크레딧 %2$@개 중 %1$@개 남음"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ 남음)"; "%@/%@ left" = "%@/%@ 남음"; @@ -921,6 +923,7 @@ "Full in ~1 regen" = "약 1회 재생성 후 가득 참"; "Full in ~%.0f regens" = "약 %.0f회 재생성 후 가득 참"; "Overage usage" = "초과 사용량"; +"Overage credits left" = "남은 초과 크레딧"; "Overage cost" = "초과 비용"; "credits" = "크레딧"; "Zen balance" = "Zen 잔액"; @@ -1472,3 +1475,34 @@ "Copy JSON" = "JSON 복사"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "조건부"; +"menu_bar_layout_group_conditionals" = "조건"; +"menu_bar_layout_conditional_add" = "새 조건"; +"menu_bar_layout_conditional_edit" = "조건 편집"; +"menu_bar_layout_conditional_remove" = "라이브러리에서 제거"; +"menu_bar_layout_conditional_save" = "저장"; +"menu_bar_layout_conditional_if" = "만약"; +"menu_bar_layout_conditional_then" = "표시"; +"menu_bar_layout_conditional_else" = "그 외에는 표시"; +"menu_bar_layout_conditional_and" = "그리고"; +"menu_bar_layout_conditional_or" = "또는"; +"menu_bar_layout_conditional_add_condition" = "조건 추가"; +"menu_bar_layout_conditional_none" = "아직 조건이 없습니다"; +"menu_bar_layout_conditional_hide" = "숨기기"; +"menu_bar_layout_conditional_name" = "이름"; +"menu_bar_layout_conditional_name_placeholder" = "예: 세션 확인"; +"menu_bar_layout_conditional_name_error" = "고유한 이름을 입력하세요"; +"menu_bar_layout_conditional_used" = "사용"; +"menu_bar_layout_conditional_remaining" = "남음"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ 재설정까지"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "범위별 주간"; +"menu_bar_layout_conditional_duplicate" = "복제"; +"menu_bar_layout_conditional_summary" = "만약 %1$@이면 %2$@을 표시하고, 그렇지 않으면 %3$@을 표시"; +"menu_bar_layout_conditional_copy_name" = "%@ (복사본)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (복사본 %2$d)"; +"menu_bar_layout_conditional_unavailable" = "조건을 사용할 수 없음"; +"menu_bar_layout_conditional_default_session_busy" = "세션 50% 초과 사용"; +"menu_bar_layout_conditional_default_weekly_high" = "주간 90% 초과 사용"; +"menu_bar_layout_conditional_default_session_spent" = "세션 거의 소진"; +"menu_bar_layout_conditional_default_either_high" = "세션 또는 주간 사용량 높음"; +"menu_bar_layout_conditional_default_scoped_weekly" = "모델별 주간 60% 초과 사용"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index dc08a5b4d..a169b737b 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -931,6 +931,7 @@ "Monthly quota" = "Maandelijks"; "Sonnet" = "Sonnet"; "Overages" = "Overschotten"; +"Overage" = "Overschot"; "Activity" = "Activiteit"; "Copied" = "Gekopieerd"; "Copy error" = "Kopieerfout"; @@ -941,6 +942,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Saldo-updates in bijna realtime (tot 5 minuten vertraging)"; "Daily billing data finalizes at 07:00 UTC" = "De dagelijkse factureringsgegevens worden afgerond om 07:00 UTC"; "%@ of %@ credits left" = "%@ van %@ credits over"; +"of %@" = "van %@"; "%@ of %@ bonus credits left" = "Er zijn nog %@ van %@ bonuscredits over"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ resterend)"; "%@/%@ left" = "%@/%@ over"; @@ -952,6 +954,7 @@ "Full in ~1 regen" = "Volledig in ~1 regeneratie"; "Full in ~%.0f regens" = "Volledig in ~%.0f regens"; "Overage usage" = "Overmatig gebruik"; +"Overage credits left" = "Resterende overschrijdingscredits"; "Overage cost" = "Overschrijdingskosten"; "credits" = "tegoeden"; "Zen balance" = "Zen-balans"; @@ -1502,3 +1505,34 @@ "Copy JSON" = "JSON kopiëren"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Voorwaardelijk"; +"menu_bar_layout_group_conditionals" = "Voorwaarden"; +"menu_bar_layout_conditional_add" = "Nieuwe voorwaarde"; +"menu_bar_layout_conditional_edit" = "Voorwaarde bewerken"; +"menu_bar_layout_conditional_remove" = "Verwijder uit bibliotheek"; +"menu_bar_layout_conditional_save" = "Bewaar"; +"menu_bar_layout_conditional_if" = "Als"; +"menu_bar_layout_conditional_then" = "Toon dan"; +"menu_bar_layout_conditional_else" = "Anders tonen"; +"menu_bar_layout_conditional_and" = "en"; +"menu_bar_layout_conditional_or" = "of"; +"menu_bar_layout_conditional_add_condition" = "Voorwaarde toevoegen"; +"menu_bar_layout_conditional_none" = "Nog geen voorwaarden"; +"menu_bar_layout_conditional_hide" = "Verbergen"; +"menu_bar_layout_conditional_name" = "Naam"; +"menu_bar_layout_conditional_name_placeholder" = "bijv. Sessiecontrole"; +"menu_bar_layout_conditional_name_error" = "Voer een unieke naam in"; +"menu_bar_layout_conditional_used" = "gebruikt"; +"menu_bar_layout_conditional_remaining" = "resterend"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ reset over"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Wekelijks per bereik"; +"menu_bar_layout_conditional_duplicate" = "Dupliceren"; +"menu_bar_layout_conditional_summary" = "Als %1$@ dan %2$@ anders %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopie)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopie %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Voorwaarde niet beschikbaar"; +"menu_bar_layout_conditional_default_session_busy" = "Sessie boven 50% gebruikt"; +"menu_bar_layout_conditional_default_weekly_high" = "Week boven 90% gebruikt"; +"menu_bar_layout_conditional_default_session_spent" = "Sessie bijna verbruikt"; +"menu_bar_layout_conditional_default_either_high" = "Sessie of week loopt hoog op"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Modelweek boven 60% gebruikt"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index ab00962ae..36535ba74 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -940,6 +940,7 @@ "Monthly quota" = "Miesięcznie"; "Sonnet" = "Sonnet"; "Overages" = "Nadwyżki"; +"Overage" = "Nadwyżka"; "Activity" = "Aktywność"; "Copied" = "Skopiowano"; "Copy error" = "Błąd kopiowania"; @@ -950,6 +951,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Saldo aktualizuje się prawie w czasie rzeczywistym (opóźnienie do 5 min)"; "Daily billing data finalizes at 07:00 UTC" = "Dzienne dane rozliczeniowe finalizują się o 07:00 UTC"; "%@ of %@ credits left" = "%@ z %@ kredytów pozostało"; +"of %@" = "z %@"; "%@ of %@ bonus credits left" = "%@ z %@ bonusowych kredytów pozostało"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ pozostało)"; "%@/%@ left" = "%@/%@ pozostało"; @@ -961,6 +963,7 @@ "Full in ~1 regen" = "Pełne za ~1 odnowienie"; "Full in ~%.0f regens" = "Pełne za ~%.0f odnowień"; "Overage usage" = "Użycie nadwyżki"; +"Overage credits left" = "Pozostałe kredyty nadwyżkowe"; "Overage cost" = "Koszt nadwyżki"; "credits" = "kredyty"; "Zen balance" = "Saldo Zen"; @@ -1506,3 +1509,34 @@ "Copy JSON" = "Kopiuj JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Warunkowy"; +"menu_bar_layout_group_conditionals" = "Warunki"; +"menu_bar_layout_conditional_add" = "Nowy warunek"; +"menu_bar_layout_conditional_edit" = "Edytuj warunek"; +"menu_bar_layout_conditional_remove" = "Usuń z biblioteki"; +"menu_bar_layout_conditional_save" = "Zapisz"; +"menu_bar_layout_conditional_if" = "Jeśli"; +"menu_bar_layout_conditional_then" = "Pokaż"; +"menu_bar_layout_conditional_else" = "W przeciwnym razie pokaż"; +"menu_bar_layout_conditional_and" = "i"; +"menu_bar_layout_conditional_none" = "Brak jeszcze warunków"; +"menu_bar_layout_conditional_hide" = "Ukryj"; +"menu_bar_layout_conditional_name" = "Nazwa"; +"menu_bar_layout_conditional_name_placeholder" = "np. Sprawdzenie sesji"; +"menu_bar_layout_conditional_name_error" = "Wprowadź unikalną nazwę"; +"menu_bar_layout_conditional_used" = "użyte"; +"menu_bar_layout_conditional_remaining" = "pozostałe"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ zeruje się za"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Tygodniowe wg zakresu"; +"menu_bar_layout_conditional_duplicate" = "Duplikuj"; +"menu_bar_layout_conditional_or" = "lub"; +"menu_bar_layout_conditional_add_condition" = "Dodaj warunek"; +"menu_bar_layout_conditional_summary" = "Jeśli %1$@, to %2$@, w przeciwnym razie %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Warunek niedostępny"; +"menu_bar_layout_conditional_default_session_busy" = "Sesja powyżej 50% zużycia"; +"menu_bar_layout_conditional_default_weekly_high" = "Tydzień powyżej 90% zużycia"; +"menu_bar_layout_conditional_default_session_spent" = "Sesja prawie wyczerpana"; +"menu_bar_layout_conditional_default_either_high" = "Sesja lub tydzień na wysokim poziomie"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Tydzień modelu powyżej 60% zużycia"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 5b19b8b22..13f77c13c 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -928,6 +928,7 @@ "Monthly quota" = "Mensal"; "Sonnet" = "Sonnet"; "Overages" = "Excedentes"; +"Overage" = "Excedente"; "Activity" = "Atividade"; "Copied" = "Copiado"; "Copy error" = "Erro ao copiar"; @@ -938,6 +939,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "O saldo atualiza quase em tempo real (até 5 min de atraso)"; "Daily billing data finalizes at 07:00 UTC" = "Os dados diários de cobrança fecham às 07:00 UTC"; "%@ of %@ credits left" = "Restam %@ de %@ créditos"; +"of %@" = "de %@"; "%@ of %@ bonus credits left" = "Restam %@ de %@ créditos bônus"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restante)"; "%@/%@ left" = "%@/%@ restante"; @@ -949,6 +951,7 @@ "Full in ~1 regen" = "Cheio em ~1 regeneração"; "Full in ~%.0f regens" = "Cheio em ~%.0f regenerações"; "Overage usage" = "Uso excedente"; +"Overage credits left" = "Créditos excedentes restantes"; "Overage cost" = "Custo excedente"; "credits" = "créditos"; "Zen balance" = "Saldo Zen"; @@ -1503,3 +1506,34 @@ "Copy JSON" = "Copiar JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionais"; +"menu_bar_layout_conditional_add" = "Novo condicional"; +"menu_bar_layout_conditional_edit" = "Editar condicional"; +"menu_bar_layout_conditional_remove" = "Remover da biblioteca"; +"menu_bar_layout_conditional_save" = "Salvar"; +"menu_bar_layout_conditional_if" = "Se"; +"menu_bar_layout_conditional_then" = "Então mostrar"; +"menu_bar_layout_conditional_else" = "Caso contrário mostrar"; +"menu_bar_layout_conditional_and" = "e"; +"menu_bar_layout_conditional_or" = "ou"; +"menu_bar_layout_conditional_add_condition" = "Adicionar condição"; +"menu_bar_layout_conditional_none" = "Nenhum condicional ainda"; +"menu_bar_layout_conditional_hide" = "Ocultar"; +"menu_bar_layout_conditional_name" = "Nome"; +"menu_bar_layout_conditional_name_placeholder" = "ex.: Verificação de sessão"; +"menu_bar_layout_conditional_name_error" = "Digite um nome exclusivo"; +"menu_bar_layout_conditional_used" = "usado"; +"menu_bar_layout_conditional_remaining" = "restante"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ reinicia em"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Semanal por escopo"; +"menu_bar_layout_conditional_duplicate" = "Duplicar"; +"menu_bar_layout_conditional_summary" = "Se %1$@ então %2$@, senão %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (cópia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (cópia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional indisponível"; +"menu_bar_layout_conditional_default_session_busy" = "Sessão acima de 50% usado"; +"menu_bar_layout_conditional_default_weekly_high" = "Semana acima de 90% usado"; +"menu_bar_layout_conditional_default_session_spent" = "Sessão quase esgotada"; +"menu_bar_layout_conditional_default_either_high" = "Sessão ou semana em nível alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semana por modelo acima de 60%"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 96b18e428..e4e41966b 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -933,6 +933,7 @@ "Monthly quota" = "Ежемесячно"; "Sonnet" = "Sonnet"; "Overages" = "Перерасходы"; +"Overage" = "Перерасход"; "Activity" = "Активность"; "Copied" = "Скопировано"; "Copy error" = "Ошибка копирования"; @@ -943,6 +944,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Обновления баланса практически в реальном времени (с задержкой до 5 минут)"; "Daily billing data finalizes at 07:00 UTC" = "Данные ежедневного биллинга фиксируются в 07:00 UTC"; "%@ of %@ credits left" = "Осталось %@ из %@ кредитов"; +"of %@" = "из %@"; "%@ of %@ bonus credits left" = "Осталось %@ из %@ бонусных кредитов"; "%@ / %@ (%@ remaining)" = "%@ / %@ (осталось %@)"; "%@/%@ left" = "%@/%@ осталось"; @@ -954,6 +956,7 @@ "Full in ~1 regen" = "Заполнится примерно за 1 пополнение"; "Full in ~%.0f regens" = "Заполнится примерно за %.0f пополнений"; "Overage usage" = "Перерасход"; +"Overage credits left" = "Оставшиеся кредиты перерасхода"; "Overage cost" = "Стоимость перерасхода"; "credits" = "кредиты"; "Zen balance" = "Баланс Zen"; @@ -1504,3 +1507,34 @@ "Copy JSON" = "Копировать JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Условный"; +"menu_bar_layout_group_conditionals" = "Условия"; +"menu_bar_layout_conditional_add" = "Новое условие"; +"menu_bar_layout_conditional_edit" = "Изменить условие"; +"menu_bar_layout_conditional_remove" = "Удалить из библиотеки"; +"menu_bar_layout_conditional_save" = "Сохранить"; +"menu_bar_layout_conditional_if" = "Если"; +"menu_bar_layout_conditional_then" = "Показать"; +"menu_bar_layout_conditional_else" = "Иначе показать"; +"menu_bar_layout_conditional_and" = "и"; +"menu_bar_layout_conditional_or" = "или"; +"menu_bar_layout_conditional_add_condition" = "Добавить условие"; +"menu_bar_layout_conditional_none" = "Условий пока нет"; +"menu_bar_layout_conditional_hide" = "Скрыть"; +"menu_bar_layout_conditional_name" = "Название"; +"menu_bar_layout_conditional_name_placeholder" = "напр., Проверка сеанса"; +"menu_bar_layout_conditional_name_error" = "Введите уникальное название"; +"menu_bar_layout_conditional_used" = "использовано"; +"menu_bar_layout_conditional_remaining" = "осталось"; +"menu_bar_layout_conditional_metric_resets_in" = "сброс %@ через"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Недельный по области"; +"menu_bar_layout_conditional_duplicate" = "Дублировать"; +"menu_bar_layout_conditional_summary" = "Если %1$@, то показать %2$@, иначе показать %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (копия)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (копия %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Условие недоступно"; +"menu_bar_layout_conditional_default_session_busy" = "Сессия использована более 50 %"; +"menu_bar_layout_conditional_default_weekly_high" = "Неделя использована более 90 %"; +"menu_bar_layout_conditional_default_session_spent" = "Сессия почти исчерпана"; +"menu_bar_layout_conditional_default_either_high" = "Сессия или неделя на высоком уровне"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Недельное окно модели более 60 %"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index f91ea3ef2..dc6f8bffd 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1078,6 +1078,7 @@ "Show usage for organizations you belong to. Personal account is always shown." = "Visa användning för organisationer du tillhör. Personligt konto visas alltid."; "%@ of %@ credits left" = "%@ av %@ krediter kvar"; "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Sparas i ~/.quotakit/config.json. Du kan också ange CODEBUFF_API_KEY eller låta QuotaKit läsa ~/.config/manicode/credentials.json (skapas av `codebuff login`)."; +"of %@" = "av %@"; "Automatic imports Windsurf session data from Chromium browser localStorage." = "Importerar Windsurf-sessionsdata från Chromium-webbläsarens localStorage automatiskt."; "Full in ~%.0f regens" = "Full om cirka %.0f regenereringar"; "Verbosity" = "Detaljnivå"; @@ -1107,6 +1108,7 @@ "CodexBar could not read the current system account on this Mac." = "QuotaKit kunde inte läsa det aktuella systemkontot på den här Mac-datorn."; "%@ of %@ bonus credits left" = "%@ av %@ bonuskrediter kvar"; "Overage usage" = "Överförbrukning"; +"Overage credits left" = "Överförbrukningskrediter kvar"; "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS-åtkomstnyckel-ID. Kan även anges med AWS_ACCESS_KEY_ID."; "CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "QuotaKit kunde inte hitta sparad autentisering för kontot. Autentisera det igen och försök igen."; "Capacity End" = "Kapacitetsslut"; @@ -1185,6 +1187,7 @@ "Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Sparas i ~/.quotakit/config.json. Hämta nyckeln från Ollama-inställningarna."; "Open Augment (Log Out & Back In)" = "Öppna Augment (logga ut och in igen)"; "Overages" = "Överförbrukning"; +"Overage" = "Överförbrukning"; "Open projects" = "Öppna projekt"; "Reported by OpenAI Admin API organization usage." = "Rapporteras av OpenAI Admin API:s organisationsanvändning."; "%@: %@ credits" = "%@: %@ krediter"; @@ -1501,3 +1504,35 @@ "Copy JSON" = "Kopiera JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Villkor"; +"menu_bar_layout_group_conditionals" = "Villkor"; +"menu_bar_layout_conditional_add" = "Nytt villkor"; +"menu_bar_layout_conditional_edit" = "Redigera villkor"; +"menu_bar_layout_conditional_remove" = "Ta bort från bibliotek"; +"menu_bar_layout_conditional_save" = "Spara"; +"menu_bar_layout_conditional_if" = "Om"; +"menu_bar_layout_conditional_then" = "Visa då"; +"menu_bar_layout_conditional_else" = "Visa annars"; +"menu_bar_layout_conditional_and" = "och"; +"menu_bar_layout_conditional_or" = "eller"; +"menu_bar_layout_conditional_add_condition" = "Lägg till villkor"; +"menu_bar_layout_conditional_none" = "Inga villkor ännu"; +"menu_bar_layout_conditional_hide" = "Dölj"; +"menu_bar_layout_conditional_name" = "Namn"; +"menu_bar_layout_conditional_name_placeholder" = "t.ex. Sessionskontroll"; +"menu_bar_layout_conditional_name_error" = "Ange ett unikt namn"; +"menu_bar_layout_conditional_used" = "använt"; +"menu_bar_layout_conditional_remaining" = "återstår"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ återställs om"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Avgränsad vecka"; +"menu_bar_layout_conditional_duplicate" = "Duplicera"; +"menu_bar_layout_conditional_summary" = "Om %1$@ visa %2$@ annars %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Villkor otillgängligt"; +"menu_bar_layout_conditional_default_session_busy" = "Session över 50 % använt"; +"menu_bar_layout_conditional_default_weekly_high" = "Vecka över 90 % använt"; +"menu_bar_layout_conditional_default_session_spent" = "Sessionen nästan förbrukad"; +"menu_bar_layout_conditional_default_either_high" = "Session eller vecka ligger högt"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Modellvecka över 60 % använt"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 803a5abc1..183c0af32 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -938,6 +938,7 @@ "Monthly quota" = "รายเดือน"; "Sonnet" = "โคลง"; "Overages" = "ส่วนเกิน"; +"Overage" = "ส่วนเกิน"; "Activity" = "กิจกรรม"; "Copied" = "คัดลอกแล้ว"; "Copy error" = "ข้อผิดพลาดในการคัดลอก"; @@ -948,6 +949,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "อัปเดตเครื่องชั่งแบบเกือบเรียลไทม์ (หน่วงสูงสุด 5 นาที)"; "Daily billing data finalizes at 07:00 UTC" = "ข้อมูลการเรียกเก็บเงินรายวันจะสรุปเวลา 07:00 น. UTC"; "%@ of %@ credits left" = "เหลือ %@ จาก %@ หน่วยกิต"; +"of %@" = "จาก %@"; "%@ of %@ bonus credits left" = "%@ จาก %@ เครดิตโบนัสที่เหลืออยู่"; "%@ / %@ (%@ remaining)" = "%@ / %@ (เหลือ %@)"; "%@/%@ left" = "เหลือ %@/%@"; @@ -959,6 +961,7 @@ "Full in ~1 regen" = "เต็มที่ ~1 รีเจน"; "Full in ~%.0f regens" = "เต็มไปด้วยการฟื้นฟู ~%.0f ครั้ง"; "Overage usage" = "การใช้งานส่วนเกิน"; +"Overage credits left" = "เครดิตส่วนเกินที่เหลือ"; "Overage cost" = "ค่าใช้จ่ายส่วนเกิน"; "credits" = "เครดิต"; "Zen balance" = "ความสมดุลแบบเซน"; @@ -1506,3 +1509,35 @@ "Copy JSON" = "คัดลอก JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "ตามเงื่อนไข"; +"menu_bar_layout_group_conditionals" = "เงื่อนไข"; +"menu_bar_layout_conditional_add" = "เงื่อนไขใหม่"; +"menu_bar_layout_conditional_edit" = "แก้ไขเงื่อนไข"; +"menu_bar_layout_conditional_remove" = "ลบออกจากไลบรารี"; +"menu_bar_layout_conditional_save" = "บันทึก"; +"menu_bar_layout_conditional_if" = "ถ้า"; +"menu_bar_layout_conditional_then" = "จากนั้นแสดง"; +"menu_bar_layout_conditional_else" = "มิฉะนั้นแสดง"; +"menu_bar_layout_conditional_and" = "และ"; +"menu_bar_layout_conditional_or" = "หรือ"; +"menu_bar_layout_conditional_add_condition" = "เพิ่มเงื่อนไข"; +"menu_bar_layout_conditional_none" = "ยังไม่มีเงื่อนไข"; +"menu_bar_layout_conditional_hide" = "ซ่อน"; +"menu_bar_layout_conditional_name" = "ชื่อ"; +"menu_bar_layout_conditional_name_placeholder" = "เช่น ตรวจสอบเซสชัน"; +"menu_bar_layout_conditional_name_error" = "ป้อนชื่อที่ไม่ซ้ำกัน"; +"menu_bar_layout_conditional_used" = "ที่ใช้"; +"menu_bar_layout_conditional_remaining" = "ที่เหลือ"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ รีเซ็ตใน"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "รายสัปดาห์ตามขอบเขต"; +"menu_bar_layout_conditional_duplicate" = "ทำสำเนา"; +"menu_bar_layout_conditional_summary" = "ถ้า %1$@ จากนั้น %2$@ มิฉะนั้น %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (สำเนา)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (สำเนา %2$d)"; +"menu_bar_layout_conditional_unavailable" = "เงื่อนไขไม่พร้อมใช้งาน"; +"menu_bar_layout_conditional_default_session_busy" = "ใช้เซสชันเกิน 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "ใช้รายสัปดาห์เกิน 90%"; +"menu_bar_layout_conditional_default_session_spent" = "เซสชันเกือบหมด"; +"menu_bar_layout_conditional_default_either_high" = "เซสชันหรือรายสัปดาห์อยู่ในระดับสูง"; +"menu_bar_layout_conditional_default_scoped_weekly" = "รายสัปดาห์ตามโมเดลเกิน 60%"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 0c7be6944..23a7e5528 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -934,6 +934,7 @@ "Monthly quota" = "Aylık"; "Sonnet" = "Sonnet"; "Overages" = "Aşmalar"; +"Overage" = "Aşım"; "Activity" = "Etkinlik"; "Copied" = "Kopyalandı"; "Copy error" = "Hata kopyala"; @@ -944,6 +945,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Bakiye neredeyse gerçek zamanlı güncellenir (en fazla 5 dk gecikme)"; "Daily billing data finalizes at 07:00 UTC" = "Günlük faturalandırma verisi 07:00 UTC'de kesinleşir"; "%@ of %@ credits left" = "%@ / %@ kredi kaldı"; +"of %@" = "/ %@"; "%@ of %@ bonus credits left" = "%@ / %@ bonus kredi kaldı"; "%@ / %@ (%@ remaining)" = "%@ / %@ (%@ kaldı)"; "%@/%@ left" = "%@/%@ kaldı"; @@ -955,6 +957,7 @@ "Full in ~1 regen" = "~1 yenilemede dolacak"; "Full in ~%.0f regens" = "~%.0f yenilemede dolacak"; "Overage usage" = "Aşım kullanımı"; +"Overage credits left" = "Kalan aşım kredileri"; "Overage cost" = "Aşım maliyeti"; "credits" = "kredi"; "Zen balance" = "Zen bakiye"; @@ -1504,3 +1507,35 @@ "Copy JSON" = "JSON kopyala"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Koşullu"; +"menu_bar_layout_group_conditionals" = "Koşullular"; +"menu_bar_layout_conditional_add" = "Yeni Koşullu"; +"menu_bar_layout_conditional_edit" = "Koşulluyu Düzenle"; +"menu_bar_layout_conditional_remove" = "Kitaplıktan Kaldır"; +"menu_bar_layout_conditional_save" = "Kaydet"; +"menu_bar_layout_conditional_if" = "Eğer"; +"menu_bar_layout_conditional_then" = "ise göster"; +"menu_bar_layout_conditional_else" = "değilse göster"; +"menu_bar_layout_conditional_and" = "ve"; +"menu_bar_layout_conditional_or" = "veya"; +"menu_bar_layout_conditional_add_condition" = "Koşul Ekle"; +"menu_bar_layout_conditional_none" = "Henüz koşullu yok"; +"menu_bar_layout_conditional_hide" = "Gizle"; +"menu_bar_layout_conditional_name" = "Ad"; +"menu_bar_layout_conditional_name_placeholder" = "örn. Oturum kontrolü"; +"menu_bar_layout_conditional_name_error" = "Benzersiz bir ad girin"; +"menu_bar_layout_conditional_used" = "kullanılan"; +"menu_bar_layout_conditional_remaining" = "kalan"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ sıfırlanma süresi"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Kapsama göre haftalık"; +"menu_bar_layout_conditional_duplicate" = "Çoğalt"; +"menu_bar_layout_conditional_summary" = "Eğer %1$@ ise %2$@, değilse %3$@ göster"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopya)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopya %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Koşul kullanılamıyor"; +"menu_bar_layout_conditional_default_session_busy" = "Oturumun %50'sinden fazlası kullanıldı"; +"menu_bar_layout_conditional_default_weekly_high" = "Haftanın %90'ından fazlası kullanıldı"; +"menu_bar_layout_conditional_default_session_spent" = "Oturum neredeyse tükendi"; +"menu_bar_layout_conditional_default_either_high" = "Oturum veya hafta yükseldi"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Model haftasının %60'ından fazlası kullanıldı"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 2a1e681a9..d347a3645 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -931,6 +931,7 @@ "Monthly quota" = "Щомісяця"; "Sonnet" = "Сонет"; "Overages" = "Надлишки"; +"Overage" = "Надлишок"; "Activity" = "діяльність"; "Copied" = "Скопійовано"; "Copy error" = "Помилка копіювання"; @@ -941,6 +942,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Оновлення балансу майже в реальному часі (затримка до 5 хвилин)"; "Daily billing data finalizes at 07:00 UTC" = "Щоденні платіжні дані завершуються о 07:00 UTC"; "%@ of %@ credits left" = "Залишилося %@ з %@ кредитів"; +"of %@" = "з %@"; "%@ of %@ bonus credits left" = "Залишилося %@ з %@ бонусних кредитів"; "%@ / %@ (%@ remaining)" = "%@ / %@ (залишилося %@)"; "%@/%@ left" = "Залишилося %@/%@"; @@ -952,6 +954,7 @@ "Full in ~1 regen" = "Повний за ~1 регенерацію"; "Full in ~%.0f regens" = "Повний ~%.0f регенерацій"; "Overage usage" = "Надмірне використання"; +"Overage credits left" = "Залишкові кредити надлишку"; "Overage cost" = "Перевищення вартості"; "credits" = "кредити"; "Zen balance" = "Дзен баланс"; @@ -1502,3 +1505,35 @@ "Copy JSON" = "Копіювати JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Умовний"; +"menu_bar_layout_group_conditionals" = "Умовні"; +"menu_bar_layout_conditional_add" = "Нова умова"; +"menu_bar_layout_conditional_edit" = "Редагувати умову"; +"menu_bar_layout_conditional_remove" = "Видалити з бібліотеки"; +"menu_bar_layout_conditional_save" = "Зберегти"; +"menu_bar_layout_conditional_if" = "Якщо"; +"menu_bar_layout_conditional_then" = "Тоді показати"; +"menu_bar_layout_conditional_else" = "Інакше показати"; +"menu_bar_layout_conditional_and" = "і"; +"menu_bar_layout_conditional_or" = "або"; +"menu_bar_layout_conditional_add_condition" = "Додати умову"; +"menu_bar_layout_conditional_none" = "Умов поки немає"; +"menu_bar_layout_conditional_hide" = "Приховати"; +"menu_bar_layout_conditional_name" = "Назва"; +"menu_bar_layout_conditional_name_placeholder" = "напр., Перевірка сеансу"; +"menu_bar_layout_conditional_name_error" = "Введіть унікальну назву"; +"menu_bar_layout_conditional_used" = "використано"; +"menu_bar_layout_conditional_remaining" = "залишилось"; +"menu_bar_layout_conditional_metric_resets_in" = "скидання %@ через"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Тижневий за областю"; +"menu_bar_layout_conditional_duplicate" = "Дублювати"; +"menu_bar_layout_conditional_summary" = "Якщо %1$@ тоді %2$@ інакше %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (копія)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (копія %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Умова недоступна"; +"menu_bar_layout_conditional_default_session_busy" = "Сесію використано понад 50 %"; +"menu_bar_layout_conditional_default_weekly_high" = "Тиждень використано понад 90 %"; +"menu_bar_layout_conditional_default_session_spent" = "Сесія майже вичерпана"; +"menu_bar_layout_conditional_default_either_high" = "Сесія або тиждень на високому рівні"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Тижневе вікно моделі понад 60 %"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 02f4062fe..badb1674c 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -927,6 +927,7 @@ "Monthly quota" = "Hàng tháng"; "Sonnet" = "Sonnet"; "Overages" = "Quá tải"; +"Overage" = "Vượt mức"; "Activity" = "Hoạt động"; "Copied" = "Đã sao chép"; "Copy error" = "Lỗi sao chép"; @@ -937,6 +938,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "Cập nhật số dư trong thời gian gần như thực (độ trễ tối đa 5 phút)"; "Daily billing data finalizes at 07:00 UTC" = "Dữ liệu thanh toán hàng ngày sẽ hoàn tất lúc 07:00 UTC"; "%@ of %@ credits left" = "%@ trong số %@ tín dụng còn lại"; +"of %@" = "/ %@"; "%@ of %@ bonus credits left" = "%@ trong số %@ tín dụng thưởng còn lại"; "%@ / %@ (%@ remaining)" = "%@ / %@ ( %@ còn lại)"; "%@/%@ left" = "%@ / %@ left"; @@ -948,6 +950,7 @@ "Full in ~1 regen" = "Đầy đủ trong ~1 regen"; "Full in ~%.0f regens" = "Đầy đủ trong ~%.0f regens"; "Overage usage" = "Quá mức Mức sử dụng"; +"Overage credits left" = "Tín dụng vượt mức còn lại"; "Overage cost" = "Chi phí quá mức"; "credits" = "tín dụng"; "Zen balance" = "Số dư Zen"; @@ -1503,3 +1506,35 @@ "Copy JSON" = "Sao chép JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Điều kiện"; +"menu_bar_layout_group_conditionals" = "Điều kiện"; +"menu_bar_layout_conditional_add" = "Điều kiện mới"; +"menu_bar_layout_conditional_edit" = "Sửa điều kiện"; +"menu_bar_layout_conditional_remove" = "Xóa khỏi thư viện"; +"menu_bar_layout_conditional_save" = "Lưu"; +"menu_bar_layout_conditional_if" = "Nếu"; +"menu_bar_layout_conditional_then" = "Thì hiển thị"; +"menu_bar_layout_conditional_else" = "Ngược lại hiển thị"; +"menu_bar_layout_conditional_and" = "và"; +"menu_bar_layout_conditional_or" = "hoặc"; +"menu_bar_layout_conditional_add_condition" = "Thêm điều kiện"; +"menu_bar_layout_conditional_none" = "Chưa có điều kiện nào"; +"menu_bar_layout_conditional_hide" = "Ẩn"; +"menu_bar_layout_conditional_name" = "Tên"; +"menu_bar_layout_conditional_name_placeholder" = "ví dụ: Kiểm tra phiên"; +"menu_bar_layout_conditional_name_error" = "Nhập tên duy nhất"; +"menu_bar_layout_conditional_used" = "đã dùng"; +"menu_bar_layout_conditional_remaining" = "còn lại"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ đặt lại sau"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "Hàng tuần theo phạm vi"; +"menu_bar_layout_conditional_duplicate" = "Nhân bản"; +"menu_bar_layout_conditional_summary" = "Nếu %1$@ thì %2$@ ngược lại %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (bản sao)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (bản sao %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Điều kiện không khả dụng"; +"menu_bar_layout_conditional_default_session_busy" = "Phiên đã dùng trên 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "Tuần đã dùng trên 90%"; +"menu_bar_layout_conditional_default_session_spent" = "Phiên gần cạn"; +"menu_bar_layout_conditional_default_either_high" = "Phiên hoặc tuần đang ở mức cao"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Tuần theo mô hình đã dùng trên 60%"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 341a208f4..87b981c02 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -898,6 +898,7 @@ "Monthly quota" = "每月"; "Sonnet" = "Sonnet"; "Overages" = "超额"; +"Overage" = "超额"; "Activity" = "活动"; "Copied" = "已复制"; "Copy error" = "复制错误"; @@ -908,6 +909,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "余额接近实时更新(最多延迟 5 分钟)"; "Daily billing data finalizes at 07:00 UTC" = "每日账单数据会在 UTC 07:00 完成结算"; "%@ of %@ credits left" = "剩余 %@ / %@ 点额度"; +"of %@" = "/ %@"; "%@ of %@ bonus credits left" = "剩余 %@ / %@ 点奖励额度"; "%@ / %@ (%@ remaining)" = "%@ / %@(剩余 %@)"; "%@/%@ left" = "剩余 %@ / %@"; @@ -919,6 +921,7 @@ "Full in ~1 regen" = "约 1 次恢复后全满"; "Full in ~%.0f regens" = "约 %.0f 次恢复后全满"; "Overage usage" = "超额用量"; +"Overage credits left" = "剩余超额额度"; "Overage cost" = "超额费用"; "credits" = "额度"; "Zen balance" = "Zen 余额"; @@ -1480,3 +1483,69 @@ "Total usage" = "总用量"; "claude_oauth_keychain_access_revoked" = "Claude Code 轮换令牌后撤销了对 Claude 钥匙串的访问权限。点击“刷新”以重新授权,或将 Claude 用量来源切换为 CLI/Web。"; "claude_showing_last_known_usage" = "正在显示 %@ 采集的最后已知用量。"; + +"menu_bar_layout_token_conditional" = "条件"; +"menu_bar_layout_group_conditionals" = "条件"; +"menu_bar_layout_conditional_add" = "新建条件"; +"menu_bar_layout_conditional_edit" = "编辑条件"; +"menu_bar_layout_conditional_remove" = "从库中移除"; +"menu_bar_layout_conditional_save" = "保存"; +"menu_bar_layout_conditional_if" = "如果"; +"menu_bar_layout_conditional_then" = "则显示"; +"menu_bar_layout_conditional_else" = "否则显示"; +"menu_bar_layout_conditional_and" = "和"; +"menu_bar_layout_conditional_or" = "或"; +"menu_bar_layout_conditional_add_condition" = "添加条件"; +"menu_bar_layout_conditional_none" = "暂无条件"; +"menu_bar_layout_conditional_hide" = "隐藏"; +"menu_bar_layout_conditional_name" = "名称"; +"menu_bar_layout_conditional_name_placeholder" = "例如:会话检查"; +"menu_bar_layout_conditional_name_error" = "请输入唯一名称"; +"menu_bar_layout_conditional_used" = "已用"; +"menu_bar_layout_conditional_remaining" = "剩余"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ 重置倒计时"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "范围每周"; +"menu_bar_layout_conditional_duplicate" = "复制"; +"menu_bar_layout_conditional_summary" = "如果 %1$@ 则 %2$@ 否则 %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@(副本)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@(副本 %2$d)"; +"menu_bar_layout_conditional_unavailable" = "条件不可用"; +"menu_bar_layout_conditional_default_session_busy" = "会话已用超过 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "每周已用超过 90%"; +"menu_bar_layout_conditional_default_session_spent" = "会话即将用尽"; +"menu_bar_layout_conditional_default_either_high" = "会话或每周用量偏高"; +"menu_bar_layout_conditional_default_scoped_weekly" = "模型专属每周已用超过 60%"; + +/* Provider usage details */ +"Detailed usage" = "用量明细"; +"Cache-hit input" = "缓存命中输入"; +"Cache-miss input" = "缓存未命中输入"; +"Daily tokens" = "每日 token"; +"Quota details" = "配额详情"; +"Token quota" = "Token 配额"; +"Credit quota" = "额度配额"; +"Session token quota" = "会话 Token 配额"; +"Session credit quota" = "会话额度配额"; +"MCP quota" = "MCP 配额"; +"Hourly tokens" = "每小时 token"; +"%@ used" = "已使用 %@"; +"%@ limit · %@ remaining" = "上限 %@ · 剩余 %@"; +"%@ (Paid: %@ / Granted: %@)" = "%@(付费:%@ / 赠送:%@)"; +"Resets every 5 hours" = "每 5 小时重置"; +"5-hour" = "5 小时"; +"%@ — add credits at %@" = "%@ — 请前往 %@ 充值"; +"Balance unavailable for API calls" = "API 调用余额不可用"; +"%@ limit" = "上限 %@"; +"%@ remaining" = "剩余 %@"; +"Quota rate" = "配额费率"; +"Peak" = "高峰"; +"Off-peak" = "非高峰"; +"peak" = "高峰"; +"off-peak" = "非高峰"; +"in %@d %@h" = "%@ 天 %@ 小时后"; +"in %@d %@m" = "%@ 天 %@ 分钟后"; +"in %@h %@m" = "%@ 小时 %@ 分钟后"; +"in %@d" = "%@ 天后"; +"in %@h" = "%@ 小时后"; +"in %@m" = "%@ 分钟后"; +"now" = "现在"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 7a785dd2d..d191da109 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -805,6 +805,7 @@ "Monthly quota" = "每月"; "Sonnet" = "Sonnet"; "Overages" = "超額"; +"Overage" = "超額"; "Activity" = "活動"; "Copied" = "已複製"; "Copy error" = "複製錯誤"; @@ -815,6 +816,7 @@ "Balance updates in near-real time (up to 5 min lag)" = "餘額接近即時更新(最多延遲 5 分鐘)"; "Daily billing data finalizes at 07:00 UTC" = "每日帳單資料會在 UTC 07:00 完成結算"; "%@ of %@ credits left" = "剩餘 %@ / %@ 點額度"; +"of %@" = "/ %@"; "%@ of %@ bonus credits left" = "剩餘 %@ / %@ 點獎勵額度"; "%@ / %@ (%@ remaining)" = "%@ / %@(剩餘 %@)"; "%@/%@ left" = "剩餘 %@ / %@"; @@ -826,6 +828,7 @@ "Full in ~1 regen" = "約 1 次恢復後全滿"; "Full in ~%.0f regens" = "約 %.0f 次恢復後全滿"; "Overage usage" = "超額使用量"; +"Overage credits left" = "剩餘超額額度"; "Overage cost" = "超額費用"; "credits" = "額度"; "Zen balance" = "Zen 餘額"; @@ -1533,3 +1536,35 @@ "Copy JSON" = "複製 JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "條件"; +"menu_bar_layout_group_conditionals" = "條件"; +"menu_bar_layout_conditional_add" = "新增條件"; +"menu_bar_layout_conditional_edit" = "編輯條件"; +"menu_bar_layout_conditional_remove" = "從庫中移除"; +"menu_bar_layout_conditional_save" = "儲存"; +"menu_bar_layout_conditional_if" = "如果"; +"menu_bar_layout_conditional_then" = "則顯示"; +"menu_bar_layout_conditional_else" = "否則顯示"; +"menu_bar_layout_conditional_and" = "和"; +"menu_bar_layout_conditional_or" = "或"; +"menu_bar_layout_conditional_add_condition" = "新增條件"; +"menu_bar_layout_conditional_none" = "暫無條件"; +"menu_bar_layout_conditional_hide" = "隱藏"; +"menu_bar_layout_conditional_name" = "名稱"; +"menu_bar_layout_conditional_name_placeholder" = "例如:工作階段檢查"; +"menu_bar_layout_conditional_name_error" = "請輸入唯一名稱"; +"menu_bar_layout_conditional_used" = "已用"; +"menu_bar_layout_conditional_remaining" = "剩餘"; +"menu_bar_layout_conditional_metric_resets_in" = "%@ 重置倒數"; +"menu_bar_layout_conditional_metric_scoped_weekly" = "範圍每週"; +"menu_bar_layout_conditional_duplicate" = "複製"; +"menu_bar_layout_conditional_summary" = "如果 %1$@ 則 %2$@ 否則 %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@(副本)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@(副本 %2$d)"; +"menu_bar_layout_conditional_unavailable" = "條件不可用"; +"menu_bar_layout_conditional_default_session_busy" = "工作階段已用超過 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "每週已用超過 90%"; +"menu_bar_layout_conditional_default_session_spent" = "工作階段即將用盡"; +"menu_bar_layout_conditional_default_either_high" = "工作階段或每週用量偏高"; +"menu_bar_layout_conditional_default_scoped_weekly" = "模型專屬每週已用超過 60%"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index af2714f50..32eb9b54d 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -441,6 +441,28 @@ extension SettingsStore { } } + var menuBarLayoutConditionals: [MenuBarLayoutConditional] { + get { self.defaultsState.menuBarLayoutConditionals } + set { + self.defaultsState.menuBarLayoutConditionals = newValue + self.persistMenuBarLayoutConditionals() + } + } + + func removeMenuBarLayoutConditional(id: UUID) { + self.menuBarLayoutConditionals.removeAll { $0.id == id } + if let stored = self.defaultsState.storedMenuBarLayout, + let stripped = stored.removingConditional(id: id) + { + self.menuBarLayout = stripped + } + for (key, layout) in self.defaultsState.menuBarLayoutOverridesRaw { + guard let stripped = layout.removingConditional(id: id) else { continue } + self.defaultsState.menuBarLayoutOverridesRaw[key] = stripped + } + self.persistMenuBarLayoutOverrides() + } + var hasStoredMenuBarLayout: Bool { self.defaultsState.storedMenuBarLayout != nil } @@ -525,6 +547,14 @@ extension SettingsStore { self.userDefaults.set(blobs.legacy, forKey: MenuBarLayoutUserDefaultsKey.layout) } + private func persistMenuBarLayoutConditionals() { + guard let blobs = try? MenuBarLayoutPersistence + .encodedLibrary(self.defaultsState.menuBarLayoutConditionals) + else { return } + self.userDefaults.set(blobs.current, forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent) + self.userDefaults.set(blobs.legacy, forKey: MenuBarLayoutUserDefaultsKey.conditionals) + } + private func persistMenuBarLayoutOverrides() { guard let blobs = try? MenuBarLayoutPersistence.encodedOverrides(self.defaultsState.menuBarLayoutOverridesRaw) else { return } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index f7949950f..1388608af 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -546,6 +546,7 @@ extension SettingsStore { let multiAccountMenuLayoutRaw = Self.loadMultiAccountMenuLayoutRaw(userDefaults: userDefaults) let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults) let storedMenuBarLayout = Self.loadMenuBarLayout(userDefaults: userDefaults) + let menuBarLayoutConditionals = Self.loadMenuBarLayoutConditionals(userDefaults: userDefaults) let menuBarLayoutOverridesRaw = Self.loadMenuBarLayoutOverrides(userDefaults: userDefaults) let menuBarLayoutSizeRaw = userDefaults.string(forKey: "menuBarLayoutSize") ?? MenuBarLayoutSize.regular.rawValue @@ -669,6 +670,7 @@ extension SettingsStore { multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, storedMenuBarLayout: storedMenuBarLayout, + menuBarLayoutConditionals: menuBarLayoutConditionals, menuBarLayoutOverridesRaw: menuBarLayoutOverridesRaw, menuBarLayoutSizeRaw: menuBarLayoutSizeRaw, menuBarLayoutGapRaw: menuBarLayoutGapRaw, @@ -932,6 +934,26 @@ extension SettingsStore { into: userDefaults) } + private static func loadMenuBarLayoutConditionals(userDefaults: UserDefaults) -> [MenuBarLayoutConditional] { + // Neither key present means a fresh install, so hand back the shipped library. Any edit, add, or + // removal writes both keys, so a library the user deliberately emptied is never reseeded. + MenuBarLayoutPersistence.loadLibrary( + current: self.decodeMenuBarLayoutConditionals( + userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent)), + legacy: self.decodeMenuBarLayoutConditionals( + userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionals)), + into: userDefaults) + ?? MenuBarLayoutConditional.shippedLibrary() + } + + /// Element-wise so one entry this build cannot understand — a library written by a newer release — + /// is dropped on its own instead of emptying the whole array. + private static func decodeMenuBarLayoutConditionals(_ data: Data?) -> [MenuBarLayoutConditional]? { + guard let data else { return nil } + return (try? JSONDecoder().decode([LenientMenuBarLayoutConditional].self, from: data))? + .compactMap(\.value) + } + private static func loadMenuBarLayoutOverrides(userDefaults: UserDefaults) -> [String: MenuBarLayout] { MenuBarLayoutPersistence.loadOverrides( current: self.decodeMenuBarLayoutOverrides( diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 95f2b16a1..6ba98d712 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -41,6 +41,7 @@ struct SettingsDefaultsState { var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] var storedMenuBarLayout: MenuBarLayout? + var menuBarLayoutConditionals: [MenuBarLayoutConditional] var menuBarLayoutOverridesRaw: [String: MenuBarLayout] var menuBarLayoutSizeRaw: String var menuBarLayoutGapRaw: String diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 429c91a4c..c534bae5c 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -15,6 +15,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { let openCodexUsageLogsEnabled: Bool let hideNativeCodexCostWhenOpenCodexPresent: Bool let hiddenSourceIDs: [String] + let menuOwnershipFingerprint: String init( costUsageEnabled: Bool, @@ -27,7 +28,8 @@ struct SpendDashboardConfiguration: Equatable, Sendable { bucketTimeZoneIdentifier: String = "", openCodexUsageLogsEnabled: Bool = false, hideNativeCodexCostWhenOpenCodexPresent: Bool = false, - hiddenSourceIDs: [String] = []) + hiddenSourceIDs: [String] = [], + menuOwnershipFingerprint: String = "") { self.costUsageEnabled = costUsageEnabled self.preferredCurrencyCode = preferredCurrencyCode @@ -40,6 +42,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { self.openCodexUsageLogsEnabled = openCodexUsageLogsEnabled self.hideNativeCodexCostWhenOpenCodexPresent = hideNativeCodexCostWhenOpenCodexPresent self.hiddenSourceIDs = hiddenSourceIDs + self.menuOwnershipFingerprint = menuOwnershipFingerprint } var bucketCalendar: Calendar { @@ -57,6 +60,12 @@ struct CodexSpendScanRequest: Equatable, Sendable { let cacheIdentity: String } +struct CodexSpendSourceDescriptor: Sendable { + let identity: String + let displayName: String + let request: CodexSpendScanRequest? +} + enum SpendDashboardRequestBuildMode: Equatable, Sendable { case refreshMissing case forceRefresh @@ -104,18 +113,28 @@ struct SpendDashboardLoadRequest: Sendable { } struct SpendDashboardLoadResult: Sendable { + enum OpenCodexObservation: Sendable, Equatable { + case disabled + case available + case confirmedEmpty + case unavailable + } + let inputs: [SpendDashboardModel.ProviderInput] let failedSourceIDs: Set let invalidatedSourceIDs: Set + let openCodexObservation: OpenCodexObservation init( inputs: [SpendDashboardModel.ProviderInput], failedSourceIDs: Set, - invalidatedSourceIDs: Set = []) + invalidatedSourceIDs: Set = [], + openCodexObservation: OpenCodexObservation = .disabled) { self.inputs = inputs self.failedSourceIDs = failedSourceIDs self.invalidatedSourceIDs = invalidatedSourceIDs + self.openCodexObservation = openCodexObservation } var failedSourceCount: Int { @@ -151,14 +170,14 @@ enum SpendDashboardSource { static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { store.discardSpendDashboardTokenPublicationsIfCostUsageDisabled() let providers = self.costCapableProviders(store: store) - let codexRequests = providers.contains(.codex) - ? self.codexRequests(settings: settings, store: store) + let codexSources = providers.contains(.codex) + ? self.codexSources(settings: settings, store: store) : [] return self.configuration( settings: settings, store: store, providers: providers, - codexRequests: codexRequests) + codexSources: codexSources) } @MainActor @@ -166,14 +185,14 @@ enum SpendDashboardSource { settings: SettingsStore, store: UsageStore, providers: [UsageProvider], - codexRequests: [CodexSpendScanRequest]) -> SpendDashboardConfiguration + codexSources: [CodexSpendSourceDescriptor]) -> SpendDashboardConfiguration { SpendDashboardConfiguration( - costUsageEnabled: settings.costUsageEnabled, + costUsageEnabled: self.spendCollectionEnabled(settings: settings, providers: providers), preferredCurrencyCode: settings.preferredCurrencyCode, providerIDs: providers.map(\.rawValue), - codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, - codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), + codexAccountIdentities: codexSources.map(\.identity), + codexAccountDisplayNames: self.codexDisplayNamesByID(codexSources), sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( providers: providers, settings: settings, @@ -182,7 +201,10 @@ enum SpendDashboardSource { bucketTimeZoneIdentifier: settings.costUsageBucketTimeZoneIdentifier, openCodexUsageLogsEnabled: settings.openCodexUsageLogsEnabled, hideNativeCodexCostWhenOpenCodexPresent: settings.hideNativeCodexCostWhenOpenCodexPresent, - hiddenSourceIDs: settings.spendDashboardHiddenSourceIDs) + hiddenSourceIDs: settings.spendDashboardHiddenSourceIDs, + menuOwnershipFingerprint: self.menuOwnershipFingerprint( + settings: settings, + providers: providers)) } @MainActor @@ -194,7 +216,8 @@ enum SpendDashboardSource { nowProvider: @escaping @Sendable () -> Date = { Date() }) async -> SpendDashboardLoadRequest { store.discardSpendDashboardTokenPublicationsIfCostUsageDisabled() - guard settings.costUsageEnabled else { + let initialProviders = self.costCapableProviders(store: store) + guard self.spendCollectionEnabled(settings: settings, providers: initialProviders) else { return SpendDashboardLoadRequest( configuration: self.configuration(settings: settings, store: store), capturedInputs: [], @@ -204,7 +227,6 @@ enum SpendDashboardSource { force: mode.forcesLoader) } - let initialProviders = self.costCapableProviders(store: store) let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in let captured = self.capturedTokenPublication(store: store, provider: provider) return ( @@ -225,14 +247,15 @@ enum SpendDashboardSource { // newest same-scope publication available at this boundary. let captureNow = now ?? nowProvider() let providers = self.costCapableProviders(store: store) - let codexRequests = providers.contains(.codex) - ? self.codexRequests(settings: settings, store: store) + let codexSources = providers.contains(.codex) + ? self.codexSources(settings: settings, store: store) : [] + let codexRequests = codexSources.compactMap(\.request) let configuration = self.configuration( settings: settings, store: store, providers: providers, - codexRequests: codexRequests) + codexSources: codexSources) guard configuration.costUsageEnabled else { return SpendDashboardLoadRequest( configuration: configuration, @@ -247,6 +270,23 @@ enum SpendDashboardSource { var unavailableSourceIDs: Set = [] var confirmedEmptySourceIDs: Set = [] for provider in providers where provider != .codex { + // Provider-specific by design: Grok local session tokens are independent of the + // remote billing snapshot, so a failed probe still publishes readable logs. + if provider == .grok { + if let snapshot = store.tokenSnapshot( + fromProviderSnapshot: store.snapshot(for: .grok), + provider: .grok, + historyDays: Self.scanDays) + { + inputs.append(SpendDashboardModel.ProviderInput( + provider: .grok, + displayName: store.metadata(for: .grok).displayName, + snapshot: snapshot)) + } else { + confirmedEmptySourceIDs.insert(UsageProvider.grok.rawValue) + } + continue + } guard let baseline = providerBaselines.first(where: { $0.provider == provider }) else { unavailableSourceIDs.insert(provider.rawValue) continue @@ -364,9 +404,11 @@ enum SpendDashboardSource { modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, snapshot: snapshot)) } + let openCodex = self.mergingOpenCodexInputsWithObservation(inputs, request: request) return SpendDashboardLoadResult( - inputs: self.mergingOpenCodexInputs(inputs, request: request), - failedSourceIDs: request.unavailableSourceIDs) + inputs: openCodex.inputs, + failedSourceIDs: request.unavailableSourceIDs, + openCodexObservation: openCodex.observation) } static func load( @@ -454,10 +496,12 @@ enum SpendDashboardSource { failedSourceIDs.formUnion(lateInvalidatedSourceIDs) invalidatedSourceIDs.formUnion(lateInvalidatedSourceIDs) inputs.removeAll { lateInvalidatedSourceIDs.contains($0.id) } + let openCodex = self.mergingOpenCodexInputsWithObservation(inputs, request: request) return SpendDashboardLoadResult( - inputs: self.mergingOpenCodexInputs(inputs, request: request), + inputs: openCodex.inputs, failedSourceIDs: failedSourceIDs, - invalidatedSourceIDs: invalidatedSourceIDs) + invalidatedSourceIDs: invalidatedSourceIDs, + openCodexObservation: openCodex.observation) } private static func snapshotContext( @@ -478,99 +522,6 @@ enum SpendDashboardSource { calendar: request.configuration.bucketCalendar) } - static func mergingOpenCodexInputs( - _ inputs: [SpendDashboardModel.ProviderInput], - request: SpendDashboardLoadRequest) -> [SpendDashboardModel.ProviderInput] - { - guard request.configuration.openCodexUsageLogsEnabled, - !request.configuration.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID) - else { return inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } } - let environment = ProcessInfo.processInfo.environment - guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { return inputs } - let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) - guard let entries = try? store.loadEntries(logURL: logURL), - !entries.isEmpty - else { return inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } } - - let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( - entries: entries, - now: request.now, - historyDays: Self.scanDays, - calendar: request.configuration.bucketCalendar) - var merged = inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } - - for (provider, supplement) in snapshots { - guard Self.shouldPublishOpenCodexSnapshot(supplement) else { continue } - // Provider-specific by design: hide-native keeps OpenCodex on its own Codex row - // so visibleInputs can drop overlapping native Codex snapshots. - if provider == .codex, - request.configuration.hideNativeCodexCostWhenOpenCodexPresent - { - merged.append(SpendDashboardModel.ProviderInput( - provider: provider, - displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, - snapshot: supplement, - sourceKind: .openCodex)) - continue - } - if let index = Self.preferredMergeIndex(for: provider, in: merged) { - merged[index] = Self.mergeProviderInput( - merged[index], - supplement: supplement, - request: request) - } else { - merged.append(SpendDashboardModel.ProviderInput( - provider: provider, - displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, - snapshot: supplement, - sourceKind: .openCodex)) - } - } - return merged - } - - static func preferredMergeIndex( - for provider: UsageProvider, - in inputs: [SpendDashboardModel.ProviderInput]) -> Int? - { - // Provider-specific by design: OpenCodex fan-out merges into the native Codex subscription row when exactly one - // exists. - if provider == .codex { - let codexIndices = inputs.indices.filter { inputs[$0].provider == .codex } - guard codexIndices.count == 1 else { return nil } - return codexIndices.first - } - let matching = inputs.indices.filter { inputs[$0].provider == provider } - guard matching.count == 1 else { - return inputs.firstIndex(where: { $0.provider == provider && $0.sourceKind == .native }) - } - return matching.first - } - - private static func mergeProviderInput( - _ input: SpendDashboardModel.ProviderInput, - supplement: CostUsageTokenSnapshot, - request: SpendDashboardLoadRequest) -> SpendDashboardModel.ProviderInput - { - SpendDashboardModel.ProviderInput( - id: input.id, - provider: input.provider, - displayName: input.displayName, - modelProviderName: input.modelProviderName, - snapshot: OpenCodexUsageFanOut.mergeSnapshots( - input.snapshot, - supplement, - now: request.now, - historyDays: self.scanDays, - calendar: request.configuration.bucketCalendar), - tokenActivityCache: input.tokenActivityCache, - sourceKind: input.sourceKind) - } - - static func shouldPublishOpenCodexSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool { - !snapshot.daily.isEmpty || !snapshot.sessions.isEmpty - } - private static func loadCodexSnapshot( _ context: CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot { @@ -597,15 +548,29 @@ enum SpendDashboardSource { @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledFirstPartyProvidersForDisplay().filter { - ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsTokenCost + store.settings.isCostUsageEffectivelyEnabled(for: $0) } } + @MainActor + private static func spendCollectionEnabled( + settings: SettingsStore, + providers: [UsageProvider]) -> Bool + { + settings.costUsageEnabled || + (providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled) + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { + self.codexSources(settings: settings, store: store).compactMap(\.request) + } + + @MainActor + static func codexSources(settings: SettingsStore, store: UsageStore) -> [CodexSpendSourceDescriptor] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts let providerName = store.metadata(for: .codex).displayName - return accounts.enumerated().compactMap { index, account in + return accounts.enumerated().map { index, account in let homePath: String? = switch account.selectionSource { case .liveSystem: settings.liveSystemCodexHomePath(forActiveSource: .liveSystem) @@ -614,30 +579,84 @@ enum SpendDashboardSource { case let .profileHome(path): settings.profileCodexHomePath(forActiveSource: .profileHome(path: path)) } - return self.codexRequest( + let request = self.codexRequest( account: account, homePath: homePath, + providerName: providerName, + index: index, + count: accounts.count, + bucketTimeZoneIdentifier: settings.costUsageBucketTimeZoneIdentifier) + let cacheIdentity = request?.cacheIdentity ?? self.sha256([ + account.id, + self.sourceToken(account.selectionSource), + CodexHomeScope.normalizedHomePath(homePath) ?? "unavailable-home", + CodexAuthFingerprint.normalize(account.authFingerprint) ?? "missing-auth", + settings.costUsageBucketTimeZoneIdentifier, + ].joined(separator: "\u{0}")) + let displayName = request?.displayName ?? self.codexDisplayName( providerName: providerName, index: index, count: accounts.count) + return CodexSpendSourceDescriptor( + identity: "\(account.id)|\(cacheIdentity)", + displayName: displayName, + request: request) } } + @MainActor + static func currentMenuOwnershipFingerprint(settings: SettingsStore, store: UsageStore) -> String { + self.menuOwnershipFingerprint( + settings: settings, + providers: self.costCapableProviders(store: store)) + } + + @MainActor + private static func menuOwnershipFingerprint( + settings: SettingsStore, + providers: [UsageProvider]) -> String + { + var parts = providers.map { provider in + "\(provider.rawValue):\(settings.providerConfigRevision(for: provider))" + } + parts.append("bucket:\(settings.costUsageBucketTimeZoneIdentifier)") + if providers.contains(.codex) { + parts.append(contentsOf: settings.codexVisibleAccountProjection.visibleAccounts.map { account in + let homePath: String? = switch account.selectionSource { + case .liveSystem: + settings.liveSystemCodexHomePath(forActiveSource: .liveSystem) + case let .managedAccount(id): + settings.managedCodexRemoteHomePath(forActiveSource: .managedAccount(id: id)) + case let .profileHome(path): + settings.profileCodexHomePath(forActiveSource: .profileHome(path: path)) + } + return [ + account.id, + self.sourceToken(account.selectionSource), + CodexHomeScope.normalizedHomePath(homePath) ?? "unavailable-home", + ].joined(separator: "|") + }) + } + return self.sha256(parts.joined(separator: "\u{0}")) + } + @MainActor private static func sourceRevisions( providers: [UsageProvider], settings: SettingsStore, store: UsageStore) -> [String] { - var revisions = ["settings:\(settings.configRevision)"] + var revisions: [String] = [] + // Provider-specific by design: regular Codex publication is a refresh trigger; account caches remain authority. if providers.contains(.codex) { revisions.append("codex-dashboard:\(store.spendDashboardCodexCostCatchUpRevision)") + revisions.append("codex-current:\(store.tokenSnapshotPublicationRevision(for: .codex))") } if settings.openCodexUsageLogsEnabled { revisions.append("opencodex:\(settings.costUsageSettingsRevision)") } revisions += providers.compactMap { provider in - // Provider-specific by design: Codex revisions come from catch-up, not captured token publications. + // Provider-specific by design: Codex inputs come from account caches, not provider-global snapshots. guard provider != .codex else { return nil } let current: CurrentProviderConfigTokenPublication? = if UsageStore.usesSpendDashboardIndependentTokenSnapshot(provider) { @@ -711,7 +730,7 @@ enum SpendDashboardSource { .map { store.tokenAccountSnapshotCacheKey(provider: provider, account: $0) } ?? "ambient" return "\(provider.rawValue):\(self.sha256(encoded)):\(self.sha256(scope)):" + - self.sha256(accountOwnership) + self.sha256("\(accountOwnership)\u{0}\(settings.costUsageBucketTimeZoneIdentifier)") } } @@ -721,6 +740,14 @@ enum SpendDashboardSource { provider: UsageProvider, publication: CurrentProviderConfigTokenPublication) -> CostUsageTokenSnapshot? { + // Provider-specific by design: Grok's catalog input is the local session scan, even when + // the remote billing snapshot is missing. + if provider == .grok { + return store.tokenSnapshot( + fromProviderSnapshot: store.snapshot(for: .grok), + provider: .grok, + historyDays: self.scanDays) + } if UsageStore.tokenCostRequiresProviderSnapshot(provider), let usage = store.snapshot(for: provider.instanceID), let derived = store.tokenSnapshot( @@ -759,7 +786,8 @@ enum SpendDashboardSource { homePath: String?, providerName: String, index: Int, - count: Int) -> CodexSpendScanRequest? + count: Int, + bucketTimeZoneIdentifier: String = "") -> CodexSpendScanRequest? { guard let homePath = CodexHomeScope.normalizedHomePath(homePath) else { return nil } var isDirectory: ObjCBool = false @@ -776,10 +804,9 @@ enum SpendDashboardSource { sourceToken, homePath, authFingerprint ?? "missing-auth", + bucketTimeZoneIdentifier, ].joined(separator: "\u{0}")) - let displayName = count == 1 - ? providerName - : "\(providerName) · #\(codexBarLocalizedInteger(index + 1))" + let displayName = self.codexDisplayName(providerName: providerName, index: index, count: count) return CodexSpendScanRequest( id: account.id, displayName: displayName, @@ -790,9 +817,16 @@ enum SpendDashboardSource { cacheIdentity: cacheIdentity) } - private static func codexDisplayNamesByID(_ requests: [CodexSpendScanRequest]) -> [String: String] { - requests.reduce(into: [:]) { result, request in - result["codex:\(request.id)"] = request.displayName + private static func codexDisplayName(providerName: String, index: Int, count: Int) -> String { + count == 1 + ? providerName + : "\(providerName) · #\(codexBarLocalizedInteger(index + 1))" + } + + private static func codexDisplayNamesByID(_ sources: [CodexSpendSourceDescriptor]) -> [String: String] { + sources.reduce(into: [:]) { result, source in + guard let separator = source.identity.lastIndex(of: "|") else { return } + result["codex:\(source.identity[.. SpendDashboardLoadRequest typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult typealias CachedLoader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult + typealias PublicationHandler = @MainActor @Sendable (SpendDashboardPublication) -> Void private enum ReconciliationObservation: Sendable { case confirmedEmpty @@ -985,6 +1020,7 @@ final class SpendDashboardController { } private(set) var model = SpendDashboardModel(requestedDays: 30, groups: []) + private(set) var publication = SpendDashboardPublication.empty private(set) var isRefreshing = false private(set) var failedSourceCount = 0 private(set) var generation: UInt64 = 0 @@ -998,6 +1034,7 @@ final class SpendDashboardController { private let cachedLoader: CachedLoader? private let loader: Loader private let nowProvider: @Sendable () -> Date + private let publicationHandler: PublicationHandler? private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] private var loadedAt = Date() @@ -1009,13 +1046,15 @@ final class SpendDashboardController { requestBuilder: @escaping RequestBuilder, cachedLoader: CachedLoader? = nil, loader: @escaping Loader = SpendDashboardSource.load, - nowProvider: @escaping @Sendable () -> Date = { Date() }) + nowProvider: @escaping @Sendable () -> Date = { Date() }, + publicationHandler: PublicationHandler? = nil) { self.userDefaults = userDefaults self.requestBuilder = requestBuilder self.cachedLoader = cachedLoader self.loader = loader self.nowProvider = nowProvider + self.publicationHandler = publicationHandler self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) } @@ -1036,6 +1075,7 @@ final class SpendDashboardController { // Same-owner revision churn during an in-flight load adopts the newer // configuration and lets the current pass finish once; handleBuiltRequest // reconciles any remaining drift after apply. + self.publishCurrentState() return } let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary @@ -1060,6 +1100,8 @@ final class SpendDashboardController { if !invalidatedSourceIDs.isEmpty { self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } + self.failedSourceIDs.subtract(invalidatedSourceIDs) + self.confirmedEmptySourceIDs.subtract(invalidatedSourceIDs) self.failedSourceCount = 0 self.rebuildModel() } @@ -1074,6 +1116,9 @@ final class SpendDashboardController { !configuration.providerIDs.isEmpty || configuration.openCodexUsageLogsEnabled else { self.loadedInputs = [] + self.failedSourceIDs = [] + self.confirmedEmptySourceIDs = [] + self.openCodexObservation = .disabled self.failedSourceCount = 0 self.isRefreshing = false self.lastSuccessfulConfiguration = configuration @@ -1084,6 +1129,7 @@ final class SpendDashboardController { } self.isRefreshing = true + self.publishCurrentState() self.loadTask = Task { [weak self] in guard let self else { return } if shouldPrimeCachedCodex, let cachedLoader = self.cachedLoader { @@ -1119,8 +1165,11 @@ final class SpendDashboardController { let cachedIDs = Set(result.inputs.map(\.id)) self.loadedInputs.removeAll { cachedIDs.contains($0.id) } self.loadedInputs.append(contentsOf: result.inputs) + self.loadedInputs = Self.stableUniqueInputs(self.loadedInputs) self.loadedAt = request.now self.failedSourceCount = result.failedSourceCount + self.failedSourceIDs = result.failedSourceIDs + self.openCodexObservation = result.openCodexObservation self.refreshRetainedCodexDisplayNames(request.configuration.codexAccountDisplayNames) self.rebuildModel() } @@ -1259,10 +1308,13 @@ final class SpendDashboardController { }.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }) } self.configuration = request.configuration - self.loadedInputs = nextInputs + self.loadedInputs = Self.stableUniqueInputs(nextInputs) self.loadedAt = request.now self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount + self.failedSourceIDs = result.failedSourceIDs + self.confirmedEmptySourceIDs = confirmedEmptySourceIDs + self.openCodexObservation = result.openCodexObservation self.isRefreshing = false self.phase = .ordinary self.loadTask = nil @@ -1301,11 +1353,15 @@ final class SpendDashboardController { inputs.append(input) capturedIDs.insert(input.id) } + let openCodex = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + inputs, + request: outcome.request) return ReconciledOutcome( result: SpendDashboardLoadResult( - inputs: SpendDashboardSource.mergingOpenCodexInputs(inputs, request: outcome.request), + inputs: openCodex.inputs, failedSourceIDs: forceFailed.union(barrierFailed), - invalidatedSourceIDs: invalidated), + invalidatedSourceIDs: invalidated, + openCodexObservation: openCodex.observation), confirmedEmptySourceIDs: outcome.confirmedEmptySourceIDs) } @@ -1319,7 +1375,7 @@ final class SpendDashboardController { guard days != self.selectedDays else { return } self.selectedDays = days self.userDefaults.set(days, forKey: Self.daysDefaultsKey) - self.rebuildModel() + self.rebuildModel(publish: false) } func selectDay(_ day: Date?) { @@ -1327,7 +1383,7 @@ final class SpendDashboardController { let normalized = day.map { calendar.startOfDay(for: $0) } guard normalized != self.selectedDay else { return } self.selectedDay = normalized - self.rebuildModel() + self.rebuildModel(publish: false) } func refreshDateWindow(now: Date? = nil) { @@ -1348,11 +1404,16 @@ final class SpendDashboardController { self.loadTask?.cancel() self.loadTask = nil self.configuration = nil + self.loadedInputs = [] + self.failedSourceIDs = [] + self.confirmedEmptySourceIDs = [] + self.openCodexObservation = .disabled self.isRefreshing = false self.phase = .ordinary + self.publishCurrentState() } - private func rebuildModel() { + private func rebuildModel(publish: Bool = true) { let configuration = self.configuration self.model = SpendDashboardModel.build( inputs: self.loadedInputs, @@ -1363,6 +1424,107 @@ final class SpendDashboardController { hiddenSourceIDs: Set(configuration?.hiddenSourceIDs ?? []), hideNativeCodexWhenOpenCodexPresent: configuration?.hideNativeCodexCostWhenOpenCodexPresent ?? false, selectedDay: self.selectedDay) + if publish { + self.publishCurrentState() + } + } + + @ObservationIgnored private var failedSourceIDs: Set = [] + @ObservationIgnored private var confirmedEmptySourceIDs: Set = [] + @ObservationIgnored private var openCodexObservation: SpendDashboardLoadResult.OpenCodexObservation = .disabled + @ObservationIgnored private var publicationRevision: UInt64 = 0 + + private func publishCurrentState() { + self.publicationRevision &+= 1 + let inputByID = Dictionary(uniqueKeysWithValues: self.loadedInputs.map { ($0.id, $0) }) + let sourceIDs = self.orderedSourceIDs(inputByID: inputByID) + var sources = sourceIDs.compactMap { sourceID -> SpendSourcePublication? in + let input = inputByID[sourceID] + guard let provider = input?.provider ?? self.provider(for: sourceID) else { return nil } + let state: SpendSourcePublication.State = if input != nil { + self.failedSourceIDs.contains(sourceID) ? .staleLastKnown : .available + } else if self.confirmedEmptySourceIDs.contains(sourceID) { + .confirmedEmpty + } else if self.isRefreshing { + .loading + } else { + .unavailable + } + return SpendSourcePublication( + id: sourceID, + provider: provider, + displayName: input?.displayName ?? self.displayName(for: sourceID, provider: provider), + role: input?.sourceKind == .openCodex ? .enrichment : .subscription, + state: state) + } + if self.configuration?.openCodexUsageLogsEnabled == true, + !sources.contains(where: { $0.id == SpendDashboardModel.openCodexSourceID }), + self.configuration?.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID) != true + { + let state: SpendSourcePublication.State = if self.isRefreshing { + .loading + } else { + switch self.openCodexObservation { + case .available: .available + case .confirmedEmpty: .confirmedEmpty + case .unavailable, .disabled: .unavailable + } + } + sources.append(SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: state)) + } + let publication = SpendDashboardPublication( + revision: self.publicationRevision, + generation: self.generation, + configuration: self.configuration, + loadedAt: self.loadedAt, + isRefreshing: self.isRefreshing, + inputs: self.loadedInputs, + sources: sources) + self.publication = publication + self.publicationHandler?(publication) + } + + private static func stableUniqueInputs( + _ inputs: [SpendDashboardModel.ProviderInput]) -> [SpendDashboardModel.ProviderInput] + { + var seen: Set = [] + return inputs.filter { seen.insert($0.id).inserted } + } + + private func orderedSourceIDs( + inputByID: [String: SpendDashboardModel.ProviderInput]) -> [String] + { + var ids: [String] = [] + for providerID in self.configuration?.providerIDs ?? [] { + if providerID == UsageProvider.codex.rawValue { + ids.append(contentsOf: (self.configuration?.codexAccountIdentities ?? []).compactMap { identity in + guard let separator = identity.lastIndex(of: "|") else { return nil } + return "codex:\(identity[.. = [] + return ids.filter { seen.insert($0).inserted } + } + + private func provider(for sourceID: String) -> UsageProvider? { + if sourceID.hasPrefix("codex:") { return .codex } + return UsageProvider(rawValue: sourceID) + } + + private func displayName(for sourceID: String, provider: UsageProvider) -> String { + self.configuration?.codexAccountDisplayNames[sourceID] + ?? ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName } private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 72dc5414c..93991b76e 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -347,9 +347,13 @@ struct SpendDashboardModel: Equatable, Sendable { hideNativeCodexWhenOpenCodexPresent: Bool) -> [ProviderInput] { var filtered = inputs.filter { !hiddenSourceIDs.contains($0.id) } - let hasOpenCodex = filtered.contains { $0.sourceKind == .openCodex } + // Provider-specific by design: only a canonical OpenCodex Codex row may replace native Codex rows. + let hasOpenCodex = filtered.contains { + $0.id == Self.openCodexSourceID && + $0.provider == .codex && + $0.sourceKind == .openCodex + } if hideNativeCodexWhenOpenCodexPresent, hasOpenCodex { - // Provider-specific by design: the OpenCodex source can explicitly replace native Codex rows. filtered.removeAll { $0.sourceKind == .native && $0.provider == .codex } } return filtered diff --git a/Sources/CodexBar/SpendDashboardPublication.swift b/Sources/CodexBar/SpendDashboardPublication.swift new file mode 100644 index 000000000..dc042100d --- /dev/null +++ b/Sources/CodexBar/SpendDashboardPublication.swift @@ -0,0 +1,198 @@ +import CodexBarCore +import Foundation + +struct SpendSourcePublication: Sendable, Equatable { + enum Role: Sendable, Equatable { + case subscription + case enrichment + } + + enum State: Sendable, Equatable { + case loading + case available + case confirmedEmpty + case unavailable + case staleLastKnown + } + + let id: String + let provider: UsageProvider? + let displayName: String + let role: Role + let state: State +} + +struct SpendDashboardPublication: Sendable { + let revision: UInt64 + let generation: UInt64 + let configuration: SpendDashboardConfiguration? + let loadedAt: Date + let isRefreshing: Bool + let inputs: [SpendDashboardModel.ProviderInput] + let sources: [SpendSourcePublication] + + static let empty = SpendDashboardPublication( + revision: 0, + generation: 0, + configuration: nil, + loadedAt: .distantPast, + isRefreshing: false, + inputs: [], + sources: []) + + func model( + requestedDays: Int, + now: Date, + calendar: Calendar, + preferredCurrencyCode: String, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false, + selectedDay: Date? = nil, + providerScope: Set? = nil) -> SpendDashboardModel + { + let staleSourceIDs = Set(self.sources.compactMap { source in + source.state == .staleLastKnown ? source.id : nil + }) + let inputs = self.inputs.filter { input in + (providerScope?.contains(input.provider) ?? true) && !staleSourceIDs.contains(input.id) + } + return SpendDashboardModel.build( + inputs: inputs, + requestedDays: requestedDays, + now: now, + calendar: calendar, + preferredCurrencyCode: preferredCurrencyCode, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent, + selectedDay: selectedDay) + } + + func subscriptionCount( + providerScope: Set, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int + { + providerScope.reduce(into: 0) { count, provider in + let rosterSources = self.subscriptionRosterSources(for: provider) + let coverageSources = self.coverageSources( + for: provider, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + if rosterSources.isEmpty, coverageSources.isEmpty { + count += hiddenSourceIDs.contains(provider.rawValue) ? 0 : 1 + } else { + count += coverageSources.count + } + } + } + + func knownCostSubscriptionCount( + model: SpendDashboardModel, + providerScope: Set, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int + { + let knownInputIDs = Set(model.groups.flatMap(\.providers).compactMap { row in + row.totalCost == nil ? nil : row.id + }) + // Provider-specific by design: Grok local session scans prove token presence but not dollar-spend coverage. + return self.knownSubscriptionCount( + knownInputIDs: knownInputIDs, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent, + confirmedEmptyIsKnown: { $0.provider != .grok }) + } + + func knownTokenSubscriptionCount( + model: SpendDashboardModel, + providerScope: Set, + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false) -> Int + { + let knownInputIDs = Set(model.groups.flatMap(\.providers).compactMap { row in + row.totalTokens == nil ? nil : row.id + }) + return self.knownSubscriptionCount( + knownInputIDs: knownInputIDs, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent, + confirmedEmptyIsKnown: { _ in true }) + } + + private func knownSubscriptionCount( + knownInputIDs: Set, + providerScope: Set, + hiddenSourceIDs: Set, + hideNativeCodexWhenOpenCodexPresent: Bool, + confirmedEmptyIsKnown: (SpendSourcePublication) -> Bool) -> Int + { + providerScope.reduce(into: 0) { count, provider in + count += self.coverageSources( + for: provider, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + .count { source in + (source.state == .confirmedEmpty && confirmedEmptyIsKnown(source)) || + (source.state == .available && knownInputIDs.contains(source.id)) + } + } + } + + private func subscriptionRosterSources(for provider: UsageProvider) -> [SpendSourcePublication] { + self.sources.filter { $0.provider == provider && $0.role == .subscription } + } + + private func coverageSources( + for provider: UsageProvider, + hiddenSourceIDs: Set, + hideNativeCodexWhenOpenCodexPresent: Bool) -> [SpendSourcePublication] + { + let rosterSources = self.subscriptionRosterSources(for: provider) + .filter { !hiddenSourceIDs.contains($0.id) } + // Provider-specific by design: OpenCodex replaces Codex coverage only with a canonical Codex payload. + guard provider == .codex else { return rosterSources } + let visibleOpenCodexInputIDs: Set = Set(self.inputs.compactMap { input -> String? in + guard input.provider == .codex, + input.sourceKind == .openCodex, + !hiddenSourceIDs.contains(input.id) + else { return nil } + return input.id + }) + let inputBackedEnrichmentSources = self.sources.filter { + $0.provider == .codex && + $0.role == .enrichment && + visibleOpenCodexInputIDs.contains($0.id) + } + let canonicalReplacement = inputBackedEnrichmentSources.first { + $0.id == SpendDashboardModel.openCodexSourceID + } + if hideNativeCodexWhenOpenCodexPresent, + let canonicalReplacement, + canonicalReplacement.state == SpendSourcePublication.State.available + { + return [canonicalReplacement] + } + if rosterSources.isEmpty, !inputBackedEnrichmentSources.isEmpty { + return inputBackedEnrichmentSources + } + // Provider-specific by design: only canonical Codex enrichment can replace Codex subscription coverage. + guard self.subscriptionRosterSources(for: provider).isEmpty, + !hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID), + let openCodexObservation = self.sources.first(where: { + $0.id == SpendDashboardModel.openCodexSourceID && + $0.provider == .codex && + $0.role == .enrichment + }) + else { return rosterSources } + let hasCodexReplacementInput = self.inputs.contains { + $0.id == SpendDashboardModel.openCodexSourceID && + $0.provider == .codex && + $0.sourceKind == .openCodex + } + return hasCodexReplacementInput || openCodexObservation.state == .confirmedEmpty + ? [openCodexObservation] + : rosterSources + } +} diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift new file mode 100644 index 000000000..b4567bb4e --- /dev/null +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation + +extension SpendDashboardSource { + static func mergingOpenCodexInputs( + _ inputs: [SpendDashboardModel.ProviderInput], + request: SpendDashboardLoadRequest) -> [SpendDashboardModel.ProviderInput] + { + self.mergingOpenCodexInputsWithObservation(inputs, request: request).inputs + } + + static func mergingOpenCodexInputsWithObservation( + _ inputs: [SpendDashboardModel.ProviderInput], + request: SpendDashboardLoadRequest, + environment: [String: String] = ProcessInfo.processInfo.environment, + entryLoader: ((URL) throws -> [OpenCodexUsageEntry])? = nil) -> ( + inputs: [SpendDashboardModel.ProviderInput], + observation: SpendDashboardLoadResult.OpenCodexObservation) + { + guard request.configuration.openCodexUsageLogsEnabled, + !request.configuration.hiddenSourceIDs.contains(SpendDashboardModel.openCodexSourceID) + else { + return ( + inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, + .disabled) + } + guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) + } + let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot()) + let entries: [OpenCodexUsageEntry] + do { + entries = try entryLoader?(logURL) ?? store.loadEntries(logURL: logURL) + } catch { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .unavailable) + } + guard !entries.isEmpty else { + return (inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID }, .confirmedEmpty) + } + + let snapshots = OpenCodexUsageFanOut.snapshotsBySubscription( + entries: entries, + now: request.now, + historyDays: Self.scanDays, + calendar: request.configuration.bucketCalendar) + var merged = inputs.filter { $0.id != SpendDashboardModel.openCodexSourceID } + var published = false + + for (provider, supplement) in snapshots { + guard Self.shouldPublishOpenCodexSnapshot(supplement) else { continue } + published = true + // Provider-specific by design: hide-native keeps OpenCodex on its own Codex row + // so visibleInputs can drop overlapping native Codex snapshots. + if provider == .codex, + request.configuration.hideNativeCodexCostWhenOpenCodexPresent + { + merged.append(SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: provider, + displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, + snapshot: supplement, + sourceKind: .openCodex)) + continue + } + if let index = Self.preferredMergeIndex(for: provider, in: merged) { + merged[index] = Self.mergeProviderInput( + merged[index], + supplement: supplement, + request: request) + } else { + merged.append(SpendDashboardModel.ProviderInput( + provider: provider, + displayName: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, + snapshot: supplement, + sourceKind: .openCodex)) + } + } + return (merged, published ? .available : .confirmedEmpty) + } + + static func preferredMergeIndex( + for provider: UsageProvider, + in inputs: [SpendDashboardModel.ProviderInput]) -> Int? + { + // Provider-specific by design: OpenCodex fan-out merges into the native Codex subscription row when exactly one + // exists. + if provider == .codex { + let codexIndices = inputs.indices.filter { inputs[$0].provider == .codex } + guard codexIndices.count == 1 else { return nil } + return codexIndices.first + } + let matching = inputs.indices.filter { inputs[$0].provider == provider } + guard matching.count == 1 else { + return inputs.firstIndex(where: { $0.provider == provider && $0.sourceKind == .native }) + } + return matching.first + } + + private static func mergeProviderInput( + _ input: SpendDashboardModel.ProviderInput, + supplement: CostUsageTokenSnapshot, + request: SpendDashboardLoadRequest) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: input.id, + provider: input.provider, + displayName: input.displayName, + modelProviderName: input.modelProviderName, + snapshot: OpenCodexUsageFanOut.mergeSnapshots( + input.snapshot, + supplement, + now: request.now, + historyDays: self.scanDays, + calendar: request.configuration.bucketCalendar), + tokenActivityCache: input.tokenActivityCache, + sourceKind: input.sourceKind) + } + + static func shouldPublishOpenCodexSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool { + !snapshot.daily.isEmpty || !snapshot.sessions.isEmpty + } +} diff --git a/Sources/CodexBar/StatusItemController+AgentSessions.swift b/Sources/CodexBar/StatusItemController+AgentSessions.swift index 37109c68d..1971060b5 100644 --- a/Sources/CodexBar/StatusItemController+AgentSessions.swift +++ b/Sources/CodexBar/StatusItemController+AgentSessions.swift @@ -1,4 +1,49 @@ import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +private struct AgentSessionMenuRowView: View { + let title: String + let width: CGFloat + + var body: some View { + Text(self.title) + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 20) + .padding(.trailing, 12) + .padding(.vertical, 4) + .frame(width: self.width, alignment: .leading) + } +} + +private enum AgentSessionMenuItemIdentifier { + private static let prefix = "agentSessionAction:" + private static let separator = "\u{1f}" + + static func make(sessionID: String, remoteHost: String?) -> NSUserInterfaceItemIdentifier { + let values = "\(remoteHost ?? "")\(self.separator)\(sessionID)" + let encoded = Data(values.utf8).base64EncodedString() + return NSUserInterfaceItemIdentifier(self.prefix + encoded) + } + + static func actionValues(from identifier: NSUserInterfaceItemIdentifier?) -> (String, String?)? { + guard let rawValue = identifier?.rawValue, + rawValue.hasPrefix(self.prefix) + else { return nil } + let encoded = rawValue.dropFirst(self.prefix.count) + guard let data = Data(base64Encoded: String(encoded)), + let values = String(data: data, encoding: .utf8) + else { return nil } + let parts = values.split(separator: Character(self.separator), maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[1].isEmpty else { return nil } + let remoteHost = parts[0].isEmpty ? nil : String(parts[0]) + return (String(parts[1]), remoteHost) + } +} extension StatusItemController { func wireAgentSessionUpdates() { @@ -44,16 +89,60 @@ extension StatusItemController { } @objc func focusAgentSession(_ sender: NSMenuItem) { - guard let values = sender.representedObject as? [String], - let sessionID = values.first + if let values = sender.representedObject as? [String], let sessionID = values.first { + let remoteHost = values.count > 1 && !values[1].isEmpty ? values[1] : nil + self.focusAgentSession(id: sessionID, remoteHost: remoteHost) + return + } + guard let (sessionID, remoteHost) = AgentSessionMenuItemIdentifier.actionValues(from: sender.identifier) else { return } - let remoteHost = values.count > 1 && !values[1].isEmpty ? values[1] : nil + self.focusAgentSession(id: sessionID, remoteHost: remoteHost) + } + + func makeAgentSessionMenuItem( + title: String, + session: AgentSession, + remoteHost: String?, + width: CGFloat) -> NSMenuItem + { + let action = MenuDescriptor.MenuAction.focusAgentSession(session, remoteHost: remoteHost) + let (selector, represented) = self.selector(for: action) + guard self.menuCardRenderingEnabledForController else { + let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") + item.target = self + item.representedObject = represented + return item + } + + // Native menu item titles contribute their full natural width to the popup. Put the text + // in a fixed-width hosted row instead, so it truncates within the width chosen by the rest + // of the menu rather than expanding the popup for an unusually long project or session name. + let item = self.makeMenuCardItem( + AgentSessionMenuRowView(title: title, width: width), + id: "agentSession:\(remoteHost ?? "local"):\(session.id)", + width: width, + heightCacheScope: "agentSession", + heightCacheFingerprint: "singleLine", + onClick: { [weak self] in + self?.focusAgentSession(id: session.id, remoteHost: remoteHost) + }) + item.toolTip = title + // The hosted row handles pointer input. Preserve AppKit's keyboard activation path too. + item.target = self + item.action = selector + // Keep the card identifier in `representedObject` for view recycling and height caching. + // The native action gets its payload from the private identifier instead. + item.identifier = AgentSessionMenuItemIdentifier.make(sessionID: session.id, remoteHost: remoteHost) + return item + } + + private func focusAgentSession(id: String, remoteHost: String?) { let session = if let remoteHost { self.agentSessions.remoteHosts .first(where: { $0.host == remoteHost })? - .sessions.first(where: { $0.id == sessionID }) + .sessions.first(where: { $0.id == id }) } else { - self.agentSessions.localSessions.first(where: { $0.id == sessionID }) + self.agentSessions.localSessions.first(where: { $0.id == id }) } guard let session else { return } self.agentSessions.focus(session, remoteHost: remoteHost) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 64f979664..794ae2e01 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -1377,8 +1377,8 @@ extension StatusItemController { if !layoutResolution.usesLegacyRendering, self.settings.menuBarIconStyle == .iconAndPercent { - let showsReset = layoutResolution.layout.lines - .joined() + let showsReset = layoutResolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) .contains { $0 == .resetCountdown || $0 == .resetAbsolute } guard showsReset else { return [] } let window = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now).automatic diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift index 17e1dd82b..f01bf8b46 100644 --- a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -23,13 +23,21 @@ extension StatusItemController { if !resolution.usesLegacyRendering, self.settings.menuBarIconStyle == .iconAndPercent { - let tokens = resolution.layout.lines.joined() + let tokens = resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) if tokens.contains(.resetCountdown) { countdownResetDates.append(contentsOf: resetDates) } if tokens.contains(.resetAbsolute) { absoluteResetDates.append(contentsOf: resetDates) } + delays += self.menuBarConditionalResetDelays( + provider: provider, + resolution: resolution, + now: now) + delays += self.menuBarConditionalElapsedDelays( + provider: provider, + resolution: resolution, + now: now) continue } @@ -55,6 +63,7 @@ extension StatusItemController { // the reset itself, whichever comes first; the next icon update schedules any later boundary. delays.append(delay) } + delays += self.menuBarWeeklyPaceRefreshDelays(providers: providers, now: now) if self.menuBarObservesCodexReset(providers: providers) { let projection = self.store.codexConsumerProjection(surface: .menuBar, now: now) @@ -111,6 +120,41 @@ extension StatusItemController { }.min() } + /// Wake at `resetsAt - threshold` for every placed reset-countdown predicate. Nothing else in the + /// layout changes at that instant, so without this the branch would only flip on the next unrelated + /// refresh. Run-out predicates are deliberately excluded: their estimate drifts with usage rather + /// than crossing a fixed instant, and `menuBarWeeklyPaceRefreshDelays` already covers that lane. + private func menuBarConditionalResetDelays( + provider: UsageProvider, + resolution: MenuBarLayoutResolution, + now: Date) + -> [TimeInterval] + { + let predicates = resolution.layout + .referencedConditionalPredicates(conditionals: self.settings.menuBarLayoutConditionals) + .filter { $0.metric.kind == .hours && $0.metric != .runsOutIn } + guard !predicates.isEmpty else { return [] } + + let snapshot = self.store.menuBarSnapshot(for: provider.instanceID) + let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now) + let scopedWeekly = MenuBarLayoutSemanticWindowResolver + .scopedWeeklyNamedWindow(snapshot: snapshot)?.window + return predicates.compactMap { predicate -> TimeInterval? in + let window: RateWindow? = switch predicate.metric { + case .sessionResetsIn: windows.session + case .weeklyResetsIn: windows.weekly + case .scopedWeeklyResetsIn: scopedWeekly + case .automaticResetsIn: windows.automatic + default: nil + } + guard let resetsAt = window?.resetsAt else { return nil } + let flipAt = resetsAt.addingTimeInterval(-predicate.threshold * 3600) + let delay = flipAt.timeIntervalSince(now) + guard delay > 0 else { return nil } + return delay + Self.menuBarCountdownRefreshEpsilon + } + } + private func menuBarRefreshProviders() -> [UsageProvider] { if self.shouldMergeIcons { return [self.primaryProviderForUnifiedIcon()] @@ -131,6 +175,79 @@ extension StatusItemController { maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).contains(.codex) } + nonisolated static func menuBarPaceRefreshDelay(window: RateWindow, now: Date) -> TimeInterval? { + guard let boundary = UsageStore.paceElapsedBoundary( + window: window, + minimumElapsedPercent: 1), + boundary > now + else { return nil } + return max( + self.menuBarCountdownRefreshEpsilon, + boundary.timeIntervalSince(now) + self.menuBarCountdownRefreshEpsilon) + } + + private func menuBarWeeklyPaceRefreshDelays( + providers: [UsageProvider], + now: Date) + -> [TimeInterval] + { + providers.compactMap { provider in + let resolution = self.settings.menuBarLayoutResolution(for: provider) + guard !resolution.usesLegacyRendering, + self.settings.menuBarIconStyle == .iconAndPercent + else { return nil } + let showsWeeklyPace = resolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .contains(where: { + if case .pace(window: .weekly) = $0 { return true } + return false + }) + // A predicate reads the same pace value with no token to detect, so it needs the same + // eligibility wake-up. + || self.referencedConditionalMetrics(resolution: resolution).contains(.weeklyPace) + guard showsWeeklyPace else { return nil } + let snapshot = self.store.menuBarSnapshot(for: provider.instanceID) + guard let window = self.menuBarLayoutWindows( + provider: provider, + snapshot: snapshot, + now: now).weekly + else { return nil } + let elapsedWindow = self.store.paceWindowForElapsedEligibility(provider: provider, window: window) + return Self.menuBarPaceRefreshDelay(window: elapsedWindow, now: now) + } + } + + /// A pace or run-out predicate compares a clock-derived value, so it needs a tick even when no token + /// does. `menuBarWeeklyPaceRefreshDelays` only wakes on the one-shot pace-eligibility boundary, so a + /// predicate-only layout would otherwise keep the branch that was true when the value last moved. + /// + /// Both numbers are pre-rounded to the granularity the menu bar shows — whole percentage points and + /// whole minutes — so a minute tick is exactly enough, and it is the cadence a `.resetCountdown` + /// token already costs. + private func menuBarConditionalElapsedDelays( + provider: UsageProvider, + resolution: MenuBarLayoutResolution, + now: Date) + -> [TimeInterval] + { + let metrics = self.referencedConditionalMetrics(resolution: resolution) + guard metrics.contains(where: \.isClockDerivedRate) else { return [] } + let secondsIntoMinute = now.timeIntervalSince1970.truncatingRemainder(dividingBy: 60) + return [max( + Self.menuBarCountdownRefreshEpsilon, + 60 - secondsIntoMinute + Self.menuBarCountdownRefreshEpsilon)] + } + + /// Metrics every conditional the layout places reads. + func referencedConditionalMetrics( + resolution: MenuBarLayoutResolution) + -> Set + { + Set(resolution.layout + .referencedConditionalPredicates(conditionals: self.settings.menuBarLayoutConditionals) + .map(\.metric)) + } + func observeMenuBarTimeEnvironmentChanges() { for name in [ Notification.Name.NSSystemClockDidChange, diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift index cbc99ec8f..f45564eaf 100644 --- a/Sources/CodexBar/StatusItemController+IconObservation.swift +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -61,6 +61,9 @@ extension StatusItemController { let layoutLaneSignature = showBrandPercent ? self.storedMenuBarLayoutLaneSignature(for: provider, snapshot: snapshot) : nil + let layoutConditionalWindowSignature = showBrandPercent + ? self.storedMenuBarLayoutConditionalWindowSignature(for: provider, snapshot: snapshot) + : nil return [ provider.rawValue, @@ -81,6 +84,7 @@ extension StatusItemController { "layoutPace=\(layoutPaceSignature ?? "nil")", "layoutBalance=\(layoutBalanceSignature ?? "nil")", "layoutLanes=\(layoutLaneSignature ?? "nil")", + "layoutCondWindows=\(layoutConditionalWindowSignature ?? "nil")", ].joined(separator: "|") } @@ -91,8 +95,9 @@ extension StatusItemController { { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering, - resolution.layout.lines.joined().contains(.accountLabel), - let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) + resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .contains(.accountLabel), + let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) else { return nil } var hasher = Hasher() @@ -104,15 +109,20 @@ extension StatusItemController { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering else { return nil } - let tokens = resolution.layout.lines.joined() - let showsToday = tokens.contains(.costToday) - let showsLast30Days = tokens.contains(.cost30d) + let tokens = resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + let metrics = self.referencedConditionalMetrics(resolution: resolution) + let showsToday = tokens.contains(.costToday) || metrics.contains(.costToday) + let showsLast30Days = tokens.contains(.cost30d) || metrics.contains(.cost30d) guard showsToday || showsLast30Days else { return nil } - let costs = self.menuBarLayoutCostStrings(provider: provider) + let costs = self.menuBarLayoutCosts(provider: provider) return [ "today=\(showsToday ? costs.today ?? "nil" : "unused")", "last30Days=\(showsLast30Days ? costs.last30Days ?? "nil" : "unused")", + // Predicates compare the unrounded amounts, and two token-cost updates can cross a + // threshold while both format to the same cent, so a conditional also signs the numbers. + "todayUSD=\(metrics.contains(.costToday) ? Self.exactSignatureValue(costs.todayUSD) : "unused")", + "last30DaysUSD=\(metrics.contains(.cost30d) ? Self.exactSignatureValue(costs.last30DaysUSD) : "unused")", ].joined(separator: ",") } @@ -122,16 +132,30 @@ extension StatusItemController { -> String? { let resolution = self.settings.menuBarLayoutResolution(for: provider) - guard !resolution.usesLegacyRendering, - resolution.layout.lines.joined().contains(.balance) - else { return nil } - return MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot) + guard !resolution.usesLegacyRendering else { return nil } + let showsBalance = resolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .contains(.balance) + || self.referencedConditionalMetrics(resolution: resolution).contains(.balance) + guard showsBalance else { return nil } + // The rendered text only carries the remaining row. A `balance used` predicate reads the "Used" + // row instead, which no display token surfaces, so sign both amounts exactly. + let amounts = MenuBarLayoutBalanceResolver.balanceAmountsUSD(provider: provider, snapshot: snapshot) + return [ + "text=\(MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot) ?? "nil")", + "remaining=\(Self.exactSignatureValue(amounts.remaining))", + "used=\(Self.exactSignatureValue(amounts.used))", + ].joined(separator: ",") } /// Pace tokens change with the historical dataset, the work-day setting, and the clock — none of /// which move the percent fields above. Without this contribution a `historicalPaceRevision` bump /// wakes the observer but leaves the signature unchanged, so a custom pace token would keep its /// stale value until an unrelated icon change forces a redraw. + /// + /// Conditional predicates on pace and run-out have the same dependency with no token to detect, so + /// they widen the window set and contribute the run-out estimate itself: `runsOutMinutes` moves at + /// minute granularity while the pace text only moves at whole-percent granularity. private func storedMenuBarLayoutPaceSignature( for provider: UsageProvider, snapshot: UsageSnapshot?) @@ -140,14 +164,22 @@ extension StatusItemController { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering else { return nil } - let paceWindows = Set(resolution.layout.lines.joined().compactMap { token -> PercentWindow? in - guard case let .pace(window) = token else { return nil } - return window - }) - guard !paceWindows.isEmpty else { return nil } + let metrics = self.referencedConditionalMetrics(resolution: resolution) + var paceWindows = Set(resolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .compactMap { token -> PercentWindow? in + guard case let .pace(window) = token else { return nil } + return window + }) + if metrics.contains(.sessionPace) { paceWindows.insert(.session) } + if metrics.contains(.weeklyPace) { paceWindows.insert(.weekly) } + if metrics.contains(.automaticPace) { paceWindows.insert(.automatic) } + let needsRunsOut = metrics.contains(.runsOutIn) + guard !paceWindows.isEmpty || needsRunsOut else { return nil } - let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: Date()) - return PercentWindow.allCases + let now = Date() + let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now) + var components = PercentWindow.allCases .filter(paceWindows.contains) .map { percentWindow in let window: RateWindow? = switch percentWindow { @@ -156,16 +188,30 @@ extension StatusItemController { case .scopedWeekly: nil case .automatic: windows.automatic } - let pace = self.store.menuBarLayoutPaceText(provider: provider, window: window) + let pace = self.store.menuBarLayoutPaceText( + provider: provider, + window: window, + now: now, + minimumElapsedPercent: percentWindow == .weekly ? 1 : nil) return "\(percentWindow.rawValue)=\(pace ?? "nil")" } - .joined(separator: ",") + if needsRunsOut { + let runsOutMinutes = (windows.weekly ?? windows.automatic) + .flatMap { self.store.weeklyPace(provider: provider, window: $0, now: now) } + .flatMap(\.etaSeconds) + .map { Int(($0 / 60).rounded()) } + components.append("runsOut=\(runsOutMinutes.map { String($0) } ?? "nil")") + } + return components.joined(separator: ",") } /// Direct lane tokens read `snapshot.tertiary` independently of the legacy icon percent /// resolver. Without this contribution a Third Party (or equivalent) lane can move while the /// observation signature stays put, so the custom token keeps a stale percent until an /// unrelated icon change forces a redraw. + /// + /// This covers what the layout *renders*, so it signs the displayed reading. + /// `storedMenuBarLayoutConditionalWindowSignature` covers what conditionals *read*. private func storedMenuBarLayoutLaneSignature( for provider: UsageProvider, snapshot: UsageSnapshot?) @@ -174,7 +220,11 @@ extension StatusItemController { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering else { return nil } - let lanes = resolution.layout.selectedLanes + // `selectedLanes` never walks conditional branches, so read the flattened tokens instead: a + // `lanePercent` inside a then/else branch renders and must be signed like any placed token. + let lanes = Set(resolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .compactMap(\.selectedLane)) guard !lanes.isEmpty else { return nil } let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: Date()) @@ -182,14 +232,68 @@ extension StatusItemController { return MenuBarLayoutLane.allCases .filter(lanes.contains) .map { lane in - let window: RateWindow? = switch lane { - case .primary: windows.primary - case .secondary: windows.secondary - case .tertiary: windows.tertiary - } - let percent = showUsed ? window?.usedPercent : window?.remainingPercent + let percent = showUsed + ? Self.laneWindow(lane, in: windows)?.usedPercent + : Self.laneWindow(lane, in: windows)?.remainingPercent return "\(lane.rawValue)=\(Self.iconSignatureValue(percent))" } .joined(separator: ",") } + + /// Window readings conditional predicates depend on but no display token exposes. + /// + /// The rendered percent follows `usageBarsShowUsed` and `remainingPercent` clamps at zero, while + /// `RateWindow.usedPercent` deliberately preserves raw over-quota values — so a used-direction + /// predicate such as `primaryLane > 105%` can flip from 104% to 106% while the displayed reading + /// stays pinned at `0.000`. Countdown predicates depend on `resetsAt`, which no token contributes at + /// all. Signing the raw used percent covers both directions, since remaining is derived from it. + private func storedMenuBarLayoutConditionalWindowSignature( + for provider: UsageProvider, + snapshot: UsageSnapshot?) + -> String? + { + let resolution = self.settings.menuBarLayoutResolution(for: provider) + guard !resolution.usesLegacyRendering else { return nil } + let metrics = self.referencedConditionalMetrics(resolution: resolution) + .filter(\.readsRateWindow) + guard !metrics.isEmpty else { return nil } + + let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: Date()) + let scopedWeekly = MenuBarLayoutSemanticWindowResolver + .scopedWeeklyNamedWindow(snapshot: snapshot)?.window + return MenuBarConditionalMetric.allCases + .filter(metrics.contains) + .map { metric in + let window: RateWindow? = switch metric { + case .session, .sessionResetsIn: windows.session + case .weekly, .weeklyResetsIn: windows.weekly + case .scopedWeekly, .scopedWeeklyResetsIn: scopedWeekly + case .automatic, .automaticResetsIn: windows.automatic + case .primaryLane: windows.primary + case .secondaryLane: windows.secondary + case .tertiaryLane: windows.tertiary + default: nil + } + let resetsAt = window?.resetsAt?.timeIntervalSince1970 + return "\(metric.rawValue)=\(Self.iconSignatureValue(window?.usedPercent))" + + "@\(Self.exactSignatureValue(resetsAt))" + } + .joined(separator: ",") + } + + private static func laneWindow(_ lane: MenuBarLayoutLane, in windows: MenuBarLayoutWindows) -> RateWindow? { + switch lane { + case .primary: windows.primary + case .secondary: windows.secondary + case .tertiary: windows.tertiary + } + } + + /// Lossless signature component. `iconSignatureValue` rounds to three decimals, which is right for a + /// rendered percentage but can hide a threshold crossing in an unrounded currency amount or an + /// epoch timestamp. + private static func exactSignatureValue(_ value: Double?) -> String { + guard let value else { return "nil" } + return String(value.bitPattern) + } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 6f6cedd0f..0d8b9712d 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -575,9 +575,8 @@ extension StatusItemController { // Rows may be built into a detached scratch menu for in-place reconciliation; // interaction closures must always reference the live menu they end up serving. let interactionMenu = captureMenu ?? menu - let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( - activeProviders: enabledProviders) - let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders + let providerScopes = self.overviewProviderScopes(enabledProviders: enabledProviders) + let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = providerScopes.visible .compactMap { provider in guard let model = self.menuCardModel(for: provider) else { return nil } guard !model.isOverviewErrorOnly else { return nil } @@ -588,12 +587,18 @@ extension StatusItemController { let t0 = CACurrentMediaTime() defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } - let spendProviders = overviewProviders.filter { self.settings.costSummaryShowsInline(for: $0) } + let spendProviders = providerScopes.spend let spendModel = self.overviewSpendDashboardModel(providers: spendProviders) - if !spendModel.groups.isEmpty { + let spendProviderCount = self.overviewSpendSubscriptionCount(providers: spendProviders) + if spendProviderCount > 0 { + let knownCounts = self.overviewSpendKnownSubscriptionCounts( + providers: spendProviders, + model: spendModel) let spendSummary = OverviewSpendSummary( model: spendModel, - providerCount: spendProviders.count) + providerCount: spendProviderCount, + knownCostProviderCount: knownCounts.cost, + knownTokenProviderCount: knownCounts.tokens) let summaryItem = self.makeMenuCardItem( OverviewSpendSummaryCardView( summary: spendSummary, @@ -649,6 +654,21 @@ extension StatusItemController { return true } + func overviewProviderScopes( + enabledProviders: [UsageProvider]) -> (visible: [UsageProvider], spend: [UsageProvider]) + { + let visible = self.settings.reconcileMergedOverviewSelectedProviders( + activeProviders: enabledProviders) + var seenSpendProviders = Set() + let spend = enabledProviders.filter { provider in + seenSpendProviders.insert(provider).inserted && + self.settings.costSummaryShowsInline(for: provider) + } + return ( + visible: visible, + spend: spend) + } + private func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { let resolvedProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, @@ -861,6 +881,14 @@ extension StatusItemController { continue } let localizedTitle = L(title) + if case let .focusAgentSession(session, remoteHost) = action { + menu.addItem(self.makeAgentSessionMenuItem( + title: localizedTitle, + session: session, + remoteHost: remoteHost, + width: width)) + continue + } let (selector, represented) = self.selector(for: action) let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "") item.target = self @@ -933,47 +961,6 @@ extension StatusItemController { } } - private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { - let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") - let view = self.makeWrappedSecondaryTextView(text: text) - let height = self.menuTextItemHeight(for: view, width: width) - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) - item.view = view - item.isEnabled = false - item.toolTip = text - return item - } - - private func makeWrappedSecondaryTextView(text: String) -> NSView { - let container = NSView() - container.translatesAutoresizingMaskIntoConstraints = false - - let textField = NSTextField(wrappingLabelWithString: text) - textField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) - textField.textColor = NSColor.secondaryLabelColor - textField.lineBreakMode = .byWordWrapping - textField.maximumNumberOfLines = 0 - textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - textField.translatesAutoresizingMaskIntoConstraints = false - - container.addSubview(textField) - // macos-smell:disable MACOS005 - NSLayoutConstraint.activate([ - textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), - textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), - textField.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), - textField.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), - ]) - - return container - } - - private func menuTextItemHeight(for view: NSView, width: CGFloat) -> CGFloat { - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - view.layoutSubtreeIfNeeded() - return max(1, ceil(view.fittingSize.height)) - } - func makeMenu(for provider: UsageProvider?) -> NSMenu { let menu = self.makeBaseMenu() if let provider { diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 9b72eb68a..b8d819f61 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -11,6 +11,16 @@ struct MenuBarLayoutWindows { let automatic: RateWindow? } +/// Menu-bar cost values resolved in one pass: the display strings in the user's preferred currency plus +/// the same amounts in USD, which conditional predicates compare so a threshold does not shift when the +/// display currency does. +struct MenuBarLayoutCostValues { + let today: String? + let last30Days: String? + let todayUSD: Double? + let last30DaysUSD: Double? +} + extension StatusItemController { func applyStoredMenuBarLayoutIfNeeded( provider: UsageProvider, @@ -41,6 +51,7 @@ extension StatusItemController { size: self.settings.menuBarLayoutSize, highContrast: self.shouldUseHighContrastStatusItemContent, showUsed: self.settings.usageBarsShowUsed, + conditionals: self.settings.menuBarLayoutConditionals, appearanceName: appearanceName, isDebugApp: Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier), isStale: self.store.isStale(provider: provider), @@ -73,10 +84,20 @@ extension StatusItemController { let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now) let scopedNamed = MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot) let paceWindow = windows.weekly ?? windows.automatic - let runsOut = paceWindow - .flatMap { self.store.weeklyPace(provider: provider, window: $0, now: now) } + // Bind the pace itself rather than only its label: `etaSeconds` is the numeric run-out that + // conditional predicates compare, and resolving it twice would score the window twice. + let pace = paceWindow.flatMap { + self.store.weeklyPace( + provider: provider, + window: $0, + now: now) + } + let runsOut = pace .flatMap { UsagePaceText.weeklyDetail(provider: provider, pace: $0, now: now).rightLabel } - let costStrings = self.menuBarLayoutCostStrings(provider: provider, now: now) + let costs = self.menuBarLayoutCosts(provider: provider, now: now) + let balanceAmounts = MenuBarLayoutBalanceResolver.balanceAmountsUSD( + provider: provider, + snapshot: snapshot) let providerName = L(self.store.metadata(for: provider).displayName) let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) let automatic = MenuBarLayoutRenderWindow(windows.automatic) @@ -99,15 +120,38 @@ extension StatusItemController { ? Self.mistralSpendDisplayText(snapshot: snapshot) : nil, sessionPace: self.store.menuBarLayoutPaceText(provider: provider, window: windows.session, now: now), - weeklyPace: self.store.menuBarLayoutPaceText(provider: provider, window: windows.weekly, now: now), + weeklyPace: self.store.menuBarLayoutPaceText( + provider: provider, + window: windows.weekly, + now: now, + minimumElapsedPercent: 1), automaticPace: self.store.menuBarLayoutPaceText( provider: provider, window: windows.automatic, now: now), runsOut: runsOut, balance: MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot), - costToday: costStrings.today, - cost30d: costStrings.last30Days) + costToday: costs.today, + cost30d: costs.last30Days, + metrics: MenuBarLayoutRenderMetrics( + sessionPaceDelta: self.store.menuBarLayoutPaceDelta( + provider: provider, + window: windows.session, + now: now), + weeklyPaceDelta: self.store.menuBarLayoutPaceDelta( + provider: provider, + window: windows.weekly, + now: now, + minimumElapsedPercent: 1), + automaticPaceDelta: self.store.menuBarLayoutPaceDelta( + provider: provider, + window: windows.automatic, + now: now), + runsOutMinutes: pace?.etaSeconds.map { Int(($0 / 60).rounded()) }, + balanceRemainingUSD: balanceAmounts.remaining, + balanceUsedUSD: balanceAmounts.used, + costTodayUSD: costs.todayUSD, + cost30dUSD: costs.last30DaysUSD)) } func menuBarLayoutAccountLabel(provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { @@ -118,28 +162,38 @@ extension StatusItemController { : rawAccountLabel } - func menuBarLayoutCostStrings( + func menuBarLayoutCosts( provider: UsageProvider, now: Date = .init()) - -> (today: String?, last30Days: String?) + -> MenuBarLayoutCostValues { let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot let sourceCurrencyCode = snapshot?.currencyCode ?? "USD" let preferredCurrencyCode = self.settings.preferredCurrencyCode - - let today = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now).map { + let todayAmount = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now) + let last30DaysAmount = snapshot?.last30DaysCostUSD + let display = { (value: Double) in UsageFormatter.convertedCostString( - $0, + value, preferredCurrency: preferredCurrencyCode, providerCurrency: sourceCurrencyCode) } - let last30Days = snapshot?.last30DaysCostUSD.map { - UsageFormatter.convertedCostString( - $0, - preferredCurrency: preferredCurrencyCode, + // Thresholds are USD. `convertedCost` hands back the source amount unchanged when no rate exists, + // so trusting its value alone would compare €6 against a $5 threshold. Keep the datum only when + // the conversion actually landed in USD; otherwise the predicate sees no value and evaluates + // false, which is the same contract as a metric the provider does not report. + let toUSD = { (value: Double) -> Double? in + let converted = UsageFormatter.convertedCost( + value, + preferredCurrency: "USD", providerCurrency: sourceCurrencyCode) + return converted.currencyCode == "USD" ? converted.value : nil } - return (today, last30Days) + return MenuBarLayoutCostValues( + today: todayAmount.map(display), + last30Days: last30DaysAmount.map(display), + todayUSD: todayAmount.flatMap(toUSD), + last30DaysUSD: last30DaysAmount.flatMap(toUSD)) } func menuBarLayoutWindows( diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 519d668d5..2b04afd0e 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -20,7 +20,8 @@ extension StatusItemController { accountOverride: AccountInfo? = nil, historySelectionOverride: PlanUtilizationHistorySelection? = nil, planOverride: String? = nil, - subtitleOverride: String? = nil) -> UsageMenuCardView.Model? + subtitleOverride: String? = nil, + sourceLabelOverride: String? = nil) -> UsageMenuCardView.Model? { // Provider-specific by design: Codex is the historical card fallback when no enabled provider is available. let target = provider ?? self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex @@ -87,7 +88,7 @@ extension StatusItemController { tokenError = nil } - let sourceLabel = surface == .liveCard ? self.store.sourceLabel(for: target) : nil + let sourceLabel = sourceLabelOverride ?? (surface == .liveCard ? self.store.sourceLabel(for: target) : nil) // Provider-specific by design: Kilo's automatic source mode is surfaced as card fallback context. let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto let (weeklyPace, sessionEquivalentForecast) = self.resolvePaceAndForecast( diff --git a/Sources/CodexBar/StatusItemController+MenuReconcile.swift b/Sources/CodexBar/StatusItemController+MenuReconcile.swift index dafa49903..f57a6eeea 100644 --- a/Sources/CodexBar/StatusItemController+MenuReconcile.swift +++ b/Sources/CodexBar/StatusItemController+MenuReconcile.swift @@ -118,7 +118,12 @@ extension StatusItemController { let requiresNativeImageReplacement = self.shouldReplaceNativeImageItemDuringReconcile(liveItem) || self.shouldReplaceNativeImageItemDuringReconcile(newItem) - if liveItem.isSeparatorItem == newItem.isSeparatorItem, !requiresNativeImageReplacement { + let hasCompatibleItemClass = + ObjectIdentifier(type(of: liveItem)) == ObjectIdentifier(type(of: newItem)) + if liveItem.isSeparatorItem == newItem.isSeparatorItem, + hasCompatibleItemClass, + !requiresNativeImageReplacement + { if !liveItem.isSeparatorItem { self.swapMenuItemContents(liveItem, newItem) } @@ -290,6 +295,9 @@ extension StatusItemController { let liveRepresented = liveItem.representedObject liveItem.representedObject = cachedItem.representedObject cachedItem.representedObject = liveRepresented + let liveIdentifier = liveItem.identifier + liveItem.identifier = cachedItem.identifier + cachedItem.identifier = liveIdentifier swap(&liveItem.state, &cachedItem.state) let liveEnabled = liveItem.isEnabled liveItem.isEnabled = cachedItem.isEnabled diff --git a/Sources/CodexBar/StatusItemController+MenuTextRows.swift b/Sources/CodexBar/StatusItemController+MenuTextRows.swift new file mode 100644 index 000000000..789a26f69 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuTextRows.swift @@ -0,0 +1,44 @@ +import AppKit + +extension StatusItemController { + func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { + let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") + let view = self.makeWrappedSecondaryTextView(text: text) + let height = self.menuTextItemHeight(for: view, width: width) + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) + item.view = view + item.isEnabled = false + item.toolTip = text + return item + } + + private func makeWrappedSecondaryTextView(text: String) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let textField = NSTextField(wrappingLabelWithString: text) + textField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) + textField.textColor = NSColor.secondaryLabelColor + textField.lineBreakMode = .byWordWrapping + textField.maximumNumberOfLines = 0 + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textField.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(textField) + // macos-smell:disable MACOS005 + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), + textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), + textField.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), + textField.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), + ]) + + return container + } + + private func menuTextItemHeight(for view: NSView, width: CGFloat) -> CGFloat { + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + return max(1, ceil(view.fittingSize.height)) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index b9312b966..e17609688 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -90,6 +90,10 @@ extension StatusItemController { switch entry { case let .text(text, style): "text:\(style):\(text)" + case let .action(_, .focusAgentSession(session, remoteHost)): + // Session rows are fixed-width hosted views. Their title can change every scan without + // affecting popup width, so avoid both measurement work and cache churn from its text. + "focusAgentSession:\(remoteHost ?? "local"):\(session.id)" case let .action(title, action): "action:\(title):\(self.measuredStandardMenuWidthCacheToken(for: action))" case let .unavailable(title, tooltip): diff --git a/Sources/CodexBar/StatusItemController+OverviewSpend.swift b/Sources/CodexBar/StatusItemController+OverviewSpend.swift index 9819fff1b..c34015549 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSpend.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSpend.swift @@ -11,21 +11,34 @@ struct OverviewSpendSummary: Equatable { let provenanceText: String let isPartial: Bool - init(model: SpendDashboardModel, providerCount: Int) { + init( + model: SpendDashboardModel, + providerCount: Int, + knownCostProviderCount: Int? = nil, + knownTokenProviderCount: Int? = nil) + { let includedProviders = model.groups.flatMap(\.providers) let providerCount = max(max(0, providerCount), includedProviders.count) let pricedProviderCount = includedProviders.count { $0.totalCost != nil } let tokenProviderCount = includedProviders.count { $0.totalTokens != nil } - let isPartial = pricedProviderCount > 0 && pricedProviderCount < providerCount + let resolvedKnownCostProviderCount = knownCostProviderCount.map { + min(providerCount, max(pricedProviderCount, $0)) + } + let isPartial = pricedProviderCount > 0 && + (resolvedKnownCostProviderCount ?? pricedProviderCount) < providerCount self.isPartial = isPartial - self.primarySpendText = model.groups.isEmpty - ? L("Spend unavailable") - : model.groups.map { group in + if model.groups.isEmpty { + self.primarySpendText = providerCount > 0 && resolvedKnownCostProviderCount == providerCount + ? L("No usage yet") + : L("Spend unavailable") + } else { + self.primarySpendText = model.groups.map { group in let text = spendDashboardGroupCostText(group) guard isPartial, group.totalCost != nil, !text.hasPrefix("~") else { return text } return "~\(text)" }.joined(separator: " · ") + } self.providerCoverageText = L( "%d of %d subscriptions have spend", pricedProviderCount, @@ -34,11 +47,18 @@ struct OverviewSpendSummary: Equatable { let tokens = Self.safeTokenSum(model.groups.compactMap(\.totalTokens)) self.tokenText = tokens.map { let value = ShareStatsFormatting.compactCount($0) - let isPartial = tokenProviderCount < providerCount + let resolvedKnownTokenProviderCount = knownTokenProviderCount.map { + min(providerCount, max(tokenProviderCount, $0)) + } + let isPartial = if let resolvedKnownTokenProviderCount { + resolvedKnownTokenProviderCount < providerCount + } else { + tokenProviderCount < providerCount + } return L("%@ tokens", isPartial ? "~\(value)" : value) } - let coveredDays = includedProviders.count < providerCount + let coveredDays = (resolvedKnownCostProviderCount ?? includedProviders.count) < providerCount ? 0 : model.groups.map(\.coveredDayCount).min() ?? 0 self.historyCoverageText = spendDashboardCoverageText( @@ -123,10 +143,73 @@ struct OverviewSpendSummaryCardView: View { } extension StatusItemController { + func overviewSpendSubscriptionCount(providers: [UsageProvider]) -> Int { + let providerScope = Set(providers) + let publication = self.store.spendDashboardPublication + guard let configuration = publication.configuration, + configuration.menuOwnershipFingerprint == SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: self.settings, + store: self.store) + else { + return providerScope.count + } + return publication.subscriptionCount( + providerScope: providerScope, + hiddenSourceIDs: Set(self.settings.spendDashboardHiddenSourceIDs), + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent) + } + + func overviewSpendKnownSubscriptionCounts( + providers: [UsageProvider], + model: SpendDashboardModel) -> (cost: Int, tokens: Int) + { + let providerScope = Set(providers) + let publication = self.store.spendDashboardPublication + guard let configuration = publication.configuration, + configuration.menuOwnershipFingerprint == SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: self.settings, + store: self.store) + else { return (0, 0) } + let hiddenSourceIDs = Set(self.settings.spendDashboardHiddenSourceIDs) + return ( + publication.knownCostSubscriptionCount( + model: model, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent), + publication.knownTokenSubscriptionCount( + model: model, + providerScope: providerScope, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent)) + } + func overviewSpendDashboardModel( providers: [UsageProvider], now: Date = Date()) -> SpendDashboardModel { + let publication = self.store.spendDashboardPublication + if let configuration = publication.configuration { + guard configuration.menuOwnershipFingerprint == SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: self.settings, + store: self.store) + else { + return SpendDashboardModel.build( + inputs: [], + requestedDays: self.settings.costUsageHistoryDays, + now: now, + calendar: self.settings.costUsageBucketCalendar, + preferredCurrencyCode: self.settings.preferredCurrencyCode) + } + return publication.model( + requestedDays: self.settings.costUsageHistoryDays, + now: now, + calendar: self.settings.costUsageBucketCalendar, + preferredCurrencyCode: self.settings.preferredCurrencyCode, + hiddenSourceIDs: Set(self.settings.spendDashboardHiddenSourceIDs), + hideNativeCodexWhenOpenCodexPresent: self.settings.hideNativeCodexCostWhenOpenCodexPresent, + providerScope: Set(providers)) + } let inputs = providers.compactMap { provider -> SpendDashboardModel.ProviderInput? in guard let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot else { return nil @@ -140,6 +223,7 @@ extension StatusItemController { inputs: inputs, requestedDays: self.settings.costUsageHistoryDays, now: now, + calendar: self.settings.costUsageBucketCalendar, preferredCurrencyCode: self.settings.preferredCurrencyCode) } } diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index e6085be31..394e0554d 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -35,6 +35,7 @@ extension StatusItemController { self.manualRefreshTasks.removeAll() self.store.cancelForcedRefreshEnrichment() self.store.cancelRequiredRefresh() + self.store.stopSharedSpendDashboardPublication() self.menuCardRefreshMonitor.resetManualRefresh() self.screenChangeVisibilityTask?.cancel() self.screenChangeVisibilityTask = nil diff --git a/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift b/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift index 6d37c5b7b..e6cfa2aec 100644 --- a/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift +++ b/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift @@ -88,6 +88,7 @@ extension SyncCoordinator { snapshot: UsageSnapshot?) -> SyncKiroCredits? { guard provider == .kiro, let k = snapshot?.kiroUsage else { return nil } + let limits = k.usageLimits // Percent: prefer Mac-computed; otherwise derive used / total. let percent: Double? = { if k.creditsTotal > 0 { @@ -103,9 +104,15 @@ extension SyncCoordinator { bonusUsed: k.bonusCreditsUsed, bonusTotal: k.bonusCreditsTotal, bonusExpiryDays: k.bonusExpiryDays, - resetsAt: nil, + resetsAt: k.resetsAt, overageCreditsUsed: k.overageCreditsUsed, - estimatedOverageCostUSD: k.estimatedOverageCostUSD) + estimatedOverageCostUSD: limits?.currencyCode.uppercased() == "USD" || limits == nil + ? k.estimatedOverageCostUSD + : nil, + overageCreditsCap: limits?.overageCap, + overageCharges: limits?.overageCharges, + overageChargeLimit: limits?.overageChargeLimit, + overageCurrencyCode: limits?.currencyCode) } static func mapBedrockCost( diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift index 7a20e6813..f42cc5773 100644 --- a/Sources/CodexBar/UsageStore+CodexResetCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -171,6 +171,7 @@ extension ProviderFetchOutcome { strategyID: result.strategyID, strategyKind: result.strategyKind, codexResetCreditsAttempted: result.codexResetCreditsAttempted, + codexPATCredentialOwner: result.codexPATCredentialOwner, diagnostic: result.diagnostic, claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index b5e6d819b..0b24f146b 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -3,16 +3,23 @@ import Foundation @MainActor extension UsageStore { - private static let minimumPaceExpectedPercent: Double = 3 private static let backfillMaxTimestampMismatch: TimeInterval = 5 * 60 - func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> UsagePace? { + func weeklyPace( + provider: UsageProvider, + window: RateWindow, + now: Date = .init(), + minimumExpectedPercent: Double = 3, + minimumElapsedPercent: Double? = nil) -> UsagePace? + { guard window.remainingPercent > 0 else { return nil } let resolved: UsagePace? + let elapsedWindow: RateWindow let workDays = self.settings.weeklyProgressWorkDays // Provider-specific by design: only Codex's dashboard yields an account-scoped daily curve for learned pace; // other providers use the generic linear window calculation. if provider == .codex, self.settings.historicalTrackingEnabled, workDays == nil { + elapsedWindow = window let codexAccountKey = self.codexOwnershipContext().canonicalKey if self.codexHistoricalDatasetAccountKey == codexAccountKey, let historical = CodexHistoricalPaceEvaluator.evaluate( @@ -24,7 +31,9 @@ extension UsageStore { } else { resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) } + // Provider-specific by design: learned historical pacing is currently backed by Codex session history only. } else if provider == .codex, self.settings.historicalTrackingEnabled { + elapsedWindow = window // An explicit work-day schedule is the user's declared plan and takes precedence over learned history. // Keep collecting history in the background so Automatic can resume historical pacing immediately. resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) @@ -37,14 +46,17 @@ extension UsageStore { // CLI both resolve first, so skipping it here would score a billing period as a flat 30 days // and disagree with them — and a 31-day cycle would exceed the sentinel outright, dropping the // pace for the first day of every long month. - let paceWindow = ProviderDescriptorRegistry.descriptor(for: provider) - .pace - .resolvedResetWindowForPace(window) + let paceWindow = self.paceWindowForElapsedEligibility(provider: provider, window: window) + elapsedWindow = paceWindow resolved = UsagePace.weekly(window: paceWindow, now: now, defaultWindowMinutes: 10080, workDays: workDays) } guard let resolved else { return nil } - guard resolved.expectedUsedPercent >= Self.minimumPaceExpectedPercent else { return nil } + let expectedFloorMet = resolved.expectedUsedPercent >= minimumExpectedPercent + let elapsedFloorMet = minimumElapsedPercent.map { minimum in + (Self.windowElapsedPercent(window: elapsedWindow, now: now) ?? 0) >= minimum + } ?? false + guard expectedFloorMet || elapsedFloorMet else { return nil } return resolved } @@ -54,14 +66,81 @@ extension UsageStore { func menuBarLayoutPaceText( provider: UsageProvider, window: RateWindow?, - now: Date = .init()) + now: Date = .init(), + minimumExpectedPercent: Double = 3, + minimumElapsedPercent: Double? = nil) -> String? { window - .flatMap { self.weeklyPace(provider: provider, window: $0, now: now) } + .flatMap { + self.weeklyPace( + provider: provider, + window: $0, + now: now, + minimumExpectedPercent: minimumExpectedPercent, + minimumElapsedPercent: minimumElapsedPercent) + } .flatMap { MenuBarDisplayText.paceText(pace: $0) } } + /// Numeric twin of `menuBarLayoutPaceText`, rounded to whole percentage points like the text so a + /// conditional predicate always compares exactly the value the menu bar shows. + func menuBarLayoutPaceDelta( + provider: UsageProvider, + window: RateWindow?, + now: Date = .init(), + minimumExpectedPercent: Double = 3, + minimumElapsedPercent: Double? = nil) + -> Double? + { + window + .flatMap { + self.weeklyPace( + provider: provider, + window: $0, + now: now, + minimumExpectedPercent: minimumExpectedPercent, + minimumElapsedPercent: minimumElapsedPercent) + } + .map { $0.deltaPercent.rounded() } + } + + /// A learned Codex curve can stay flat near the start of a weekly window even as the window + /// itself advances. The weekly menu token uses elapsed progress as an eligibility fallback, + /// while the returned pace still retains the learned expected-use value. + func paceWindowForElapsedEligibility(provider: UsageProvider, window: RateWindow) -> RateWindow { + // Provider-specific by design: Codex's consumer projection already resolves its historical window. + guard provider != .codex, window.windowMinutes != nil else { return window } + return ProviderDescriptorRegistry.descriptor(for: provider) + .pace + .resolvedResetWindowForPace(window) + } + + nonisolated static func paceElapsedBoundary( + window: RateWindow, + minimumElapsedPercent: Double) -> Date? + { + guard minimumElapsedPercent > 0, + let resetsAt = window.resetsAt, + let windowMinutes = window.windowMinutes, + windowMinutes > 0 + else { return nil } + let duration = TimeInterval(windowMinutes) * 60 + let start = resetsAt.addingTimeInterval(-duration) + return start.addingTimeInterval(duration * minimumElapsedPercent / 100) + } + + private static func windowElapsedPercent(window: RateWindow, now: Date) -> Double? { + guard let resetsAt = window.resetsAt, + let windowMinutes = window.windowMinutes, + windowMinutes > 0 + else { return nil } + let duration = TimeInterval(windowMinutes) * 60 + let start = resetsAt.addingTimeInterval(-duration) + let elapsed = now.timeIntervalSince(start) + return min(100, max(0, elapsed / duration * 100)) + } + func recordCodexHistoricalSampleIfNeeded(snapshot: UsageSnapshot) { guard self.settings.historicalTrackingEnabled else { return } let projection = self.codexConsumerProjection( diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 17523469a..a0d6e3198 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -342,6 +342,7 @@ extension UsageStore { { guard let spec = await self.providerRefreshSpec(provider) else { return nil } guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return nil } + let codexExplicitPAT = provider == .codex && self.settings.codexUsageDataSource == .pat let codexPreparation = provider == .codex ? self.prepareCodexRefreshPublication() : nil let codexExpectedGuard = codexPreparation?.expectedGuard let codexLimitResetOwnerKey = codexPreparation?.limitResetOwnerKey @@ -434,6 +435,7 @@ extension UsageStore { let codexSuppressesWeeklyResetCelebration: Bool if provider == .codex { if case let .success(result) = initialOutcome.result, + !Self.isCodexPATOutcome(initialOutcome), let codexExpectedGuard, !self.shouldApplyCodexUsageResult( expectedGuard: codexExpectedGuard, @@ -458,6 +460,7 @@ extension UsageStore { return nil } if case let .success(result) = admission.outcome.result, + !Self.isCodexPATOutcome(admission.outcome), let codexExpectedGuard, !self.shouldApplyCodexUsageResult( expectedGuard: codexExpectedGuard, @@ -474,6 +477,12 @@ extension UsageStore { outcome = initialOutcome codexSuppressesWeeklyResetCelebration = false } + let (codexPublicationGuard, publishedCodexLimitResetOwnerKey) = Self.codexPublicationRefreshOverrides( + provider: provider, + outcome: outcome, + explicitPAT: codexExplicitPAT, + expectedGuard: codexExpectedGuard, + limitResetOwnerKey: codexLimitResetOwnerKey) let claudeReconciliation = await self.reconcileClaudeRefreshAfterFetch(input: .init( provider: provider, outcome: outcome, @@ -492,10 +501,10 @@ extension UsageStore { hasAdminAPIKey: claudeHasAdminAPIKey, hasTokenAccount: tokenAccount != nil, removedTokenAccountAuthority: tokenAccountPreparation.removesAccountAuthority), - codexExpectedGuard: codexExpectedGuard, + codexExpectedGuard: codexPublicationGuard, tokenAccount: tokenAccount, priorTokenAccountSnapshot: priorTokenAccountSnapshot, - codexLimitResetOwnerKey: codexLimitResetOwnerKey, + codexLimitResetOwnerKey: publishedCodexLimitResetOwnerKey, codexSuppressesWeeklyResetCelebration: codexSuppressesWeeklyResetCelebration, claudeOAuthHistoryPersistentRefHash: claudeReconciliation.oauthHistoryPersistentRefHash, claudeOAuthActiveAccountObservation: claudeReconciliation.oauthActiveAccountObservation) @@ -755,11 +764,17 @@ extension UsageStore { self.publishTokenSnapshot(tokenSnapshot, for: provider) self.tokenErrors[provider.instanceID] = nil self.tokenFailureGates[provider.instanceID]?.recordSuccess() + } else if provider == .xai, XAICostUsageMapping.isAnalyticsUnavailable(backfilled) { + // Provider-specific by design: prepaid balance without usage history is unavailable, + // not a confirmed-empty $0 spend row. + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider.instanceID] = nil } else if Self.tokenCostRequiresProviderSnapshot(provider) { self.publishConfirmedEmptyTokenSnapshot(for: provider) self.tokenErrors[provider.instanceID] = nil } self.lastSourceLabels[provider.instanceID] = result.sourceLabel + self.settings.persistDiscoveredFireworksAccountSlug(result.fireworksDiscoveredAccountSlug) self.recordProviderFetchSuccessErrorState(provider: provider) self.diagnostics[provider.instanceID] = result.diagnostic if let tokenAccount = currentTokenAccount { @@ -775,12 +790,10 @@ extension UsageStore { self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider.instanceID) self.failureGates[provider.instanceID]?.recordSuccess() if provider == .codex { - self.rememberLiveSystemCodexEmailIfNeeded(scoped.accountEmail(for: .codex)) - self.seedCodexAccountScopedRefreshGuard(accountEmail: scoped.accountEmail(for: .codex)) - self.lastCodexUsagePublicationGuard = self.lastCodexAccountScopedRefreshGuard - self.persistSingleCodexAccountSnapshot( - backfilled, - sourceLabel: result.sourceLabel, + self.recordCodexRefreshSuccessPublication( + scoped: scoped, + backfilled: backfilled, + result: result, expectedGuard: context.codexExpectedGuard, expectedOwnerKey: context.codexLimitResetOwnerKey) } @@ -893,7 +906,7 @@ extension UsageStore { self.lastCodexUsagePublicationGuard = expectedGuard } - private func retireCodexStateIfRefreshOwnerChanged( + func retireCodexStateIfRefreshOwnerChanged( expectedGuard: CodexAccountScopedRefreshGuard, generation: UInt64) { @@ -922,55 +935,6 @@ extension UsageStore { loginMethod: identity?.loginMethod)) } - private func persistSingleCodexAccountSnapshot( - _ snapshot: UsageSnapshot, - sourceLabel: String, - expectedGuard: CodexAccountScopedRefreshGuard?, - expectedOwnerKey: CodexLimitResetOwnerKey?) - { - guard let expectedGuard, - let expectedOwnerKey - else { return } - - let currentGuard = self.freshCodexAccountScopedRefreshGuard() - guard Self.codexScopedRefreshGuardsMatchAccount(expectedGuard, currentGuard), - let currentOwnerKey = CodexLimitResetOwnerKey( - identity: currentGuard.identity, - accountEmail: currentGuard.accountKey), - currentOwnerKey == expectedOwnerKey - else { return } - - let visibleAccounts = self.freshCodexVisibleAccountsForSnapshotHydration() - let activeMatches = visibleAccounts.filter { - $0.isActive && - $0.selectionSource == currentGuard.source && - CodexIdentityResolver.normalizeEmail($0.email) == currentGuard.accountKey - } - guard activeMatches.count == 1, - let account = activeMatches.first, - let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)), - snapshotEmail == CodexIdentityResolver.normalizeEmail(currentGuard.accountKey), - snapshotEmail == CodexIdentityResolver.normalizeEmail(account.email), - self.codexLimitResetOwnerKey( - forVisibleAccount: account, - visibleAccounts: visibleAccounts) == currentOwnerKey - else { return } - - let identity = snapshot.identity(for: .codex) - let relabeled = snapshot.withIdentity(ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: account.email, - accountOrganization: identity?.accountOrganization, - loginMethod: identity?.loginMethod ?? account.workspaceLabel)) - let currentSnapshots = [CodexAccountUsageSnapshot( - account: account, - snapshot: relabeled, - error: nil, - sourceLabel: sourceLabel)] - self.codexAccountSnapshots = currentSnapshots - self.codexAccountUsageSnapshotStore?.store(currentSnapshots) - } - private func clearDisabledProviderRefreshState(_ provider: UsageProvider) async { self.clearProviderRuntimeState(provider) } @@ -1465,7 +1429,19 @@ extension UsageStore { self.errors[provider.instanceID] = error.localizedDescription if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider.instanceID) - if Self.tokenCostRequiresProviderSnapshot(provider) { + // Provider-specific by design: local ~/.grok/sessions tokens remain readable + // when the remote billing probe fails. + if provider == .grok { + if let local = self.tokenSnapshot( + fromProviderSnapshot: nil, + provider: .grok, + historyDays: SpendDashboardSource.scanDays) + { + self.publishTokenSnapshot(local, for: provider) + } else { + self.clearTokenSnapshot(for: provider) + } + } else if Self.tokenCostRequiresProviderSnapshot(provider) { self.clearTokenSnapshot(for: provider) } } diff --git a/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift new file mode 100644 index 000000000..4c1bfb3ab --- /dev/null +++ b/Sources/CodexBar/UsageStore+SpendDashboardPublication.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Observation + +@MainActor +extension UsageStore { + func sharedSpendDashboardController() -> SpendDashboardController { + if let controller = self.sharedSpendDashboardControllerStorage { + return controller + } + let controller = SpendDashboardController( + requestBuilder: { [weak self] mode in + guard let self else { + return SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(), + force: false) + } + return await SpendDashboardSource.makeRequest( + settings: self.settings, + store: self, + mode: mode) + }, + cachedLoader: { request in + await SpendDashboardSource.loadCached(request) + }, + publicationHandler: { [weak self] publication in + self?.spendDashboardPublication = publication + }) + self.sharedSpendDashboardControllerStorage = controller + return controller + } + + func startSharedSpendDashboardPublication() { + guard !self.sharedSpendDashboardObservationStarted else { return } + self.sharedSpendDashboardObservationStarted = true + self.observeSharedSpendDashboardConfiguration() + } + + func stopSharedSpendDashboardPublication() { + self.sharedSpendDashboardObservationStarted = false + self.sharedSpendDashboardControllerStorage?.stop() + self.cancelSpendDashboardCodexCostCatchUp() + } + + private func observeSharedSpendDashboardConfiguration() { + guard self.sharedSpendDashboardObservationStarted else { return } + let configuration = withObservationTracking { + SpendDashboardSource.configuration(settings: self.settings, store: self) + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.observeSharedSpendDashboardConfiguration() + } + } + self.applySharedSpendDashboardConfiguration(configuration) + } + + func synchronizeSharedSpendDashboardAfterTokenPublication(for provider: UsageProvider) { + // Provider-specific by design: regular Codex publication triggers the account-scoped spend producer. + guard provider == .codex, self.sharedSpendDashboardObservationStarted else { return } + self.applySharedSpendDashboardConfiguration( + SpendDashboardSource.configuration(settings: self.settings, store: self)) + } + + private func applySharedSpendDashboardConfiguration(_ configuration: SpendDashboardConfiguration) { + // Provider-specific by design: Codex's multi-account 365-day scanner is the shared source producer. + let codexRequests = configuration.providerIDs.contains(UsageProvider.codex.rawValue) + ? SpendDashboardSource.codexRequests(settings: self.settings, store: self) + : [] + self.synchronizeSpendDashboardCodexCostCatchUp(accounts: codexRequests) + self.sharedSpendDashboardController().update(configuration: configuration) + } +} diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 15925fd6d..334e5703d 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -235,6 +235,9 @@ extension UsageStore { } func shouldFetchAllCodexVisibleAccounts() -> Bool { + // PAT is not a per-visible-account credential. Fan-out would fetch the same token for + // every row and then reject its whoami identity against other accounts. + guard !self.shouldUseAmbientCodexPATForUsage() else { return false } let projection = self.freshCodexVisibleAccountProjectionForAccountRefresh() return self.settings.multiAccountMenuLayout == .stacked && projection.visibleAccounts.count > 1 } @@ -1044,7 +1047,8 @@ extension UsageStore { claudeOwnerCLIRecoveryOnly: claudeOwnerCLIRecoveryOnly, persistsCLISessions: true, persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( - refreshInterval: self.normalRefreshIntervalForHeuristics())) + refreshInterval: self.normalRefreshIntervalForHeuristics()), + resolvedCLIVersion: self.version(for: provider)) } private func providerConfigMutationIsCurrent( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 9fcf20e43..a52c7ddb1 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -111,8 +111,7 @@ extension UsageStore { publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) else { return nil } return CurrentProviderConfigTokenPublication( - snapshot: publication.snapshot, - publicationRevision: publication.publicationRevision) + snapshot: publication.snapshot, publicationRevision: publication.publicationRevision) } func tokenSnapshotPublicationRevision(for provider: UsageProvider) -> UInt64 { @@ -136,6 +135,7 @@ extension UsageStore { publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + self.synchronizeSharedSpendDashboardAfterTokenPublication(for: provider) } func installCachedTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { @@ -174,6 +174,11 @@ extension UsageStore { guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) { self.publishTokenSnapshot(tokenSnapshot, for: provider) + // Provider-specific by design: a prepaid-balance snapshot without a usage chart means + // analytics failed. Leave the source unpublished so Overview counts it unavailable + // instead of known-zero spend. + } else if provider == .xai, XAICostUsageMapping.isAnalyticsUnavailable(snapshot) { + self.clearTokenSnapshot(for: provider) } else { self.publishConfirmedEmptyTokenSnapshot(for: provider) } @@ -449,6 +454,9 @@ extension UsageStore { -> CostUsageTokenSnapshot? { let windowDays = historyDays ?? self.settings.costUsageHistoryDays + // Provider-specific by design: snapshot-backed spend sources own their live billing + // projection. Grok contributes local session tokens only; xAI contributes Management API + // daily spend only. Neither converts a quota or prepaid balance into dollars. switch provider { case .openai: return snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() @@ -464,14 +472,21 @@ extension UsageStore { } case .openrouter: return snapshot?.costUsage + case .xai: + return snapshot.flatMap { XAICostUsageMapping.tokenSnapshot(from: $0, historyDays: windowDays) } + case .grok: + return GrokLocalSessionScanner.summarize(lookbackDays: windowDays) + .toCostUsageTokenSnapshot(historyDays: windowDays) default: return nil } } nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { + // Provider-specific by design: these providers project live usage snapshots into the + // shared spend catalog instead of running the local CostUsageFetcher JSONL pipeline. switch provider { - case .mistral, .openai, .opencodego, .openrouter: + case .grok, .mistral, .openai, .opencodego, .openrouter, .xai: true default: false diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index d3f7c1d80..7b02191dc 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -111,7 +111,7 @@ extension UsageStore { provider: accountSnapshot.provider.instanceID, deviceID: deviceID, accountIdentity: identity, - displayLabel: accountSnapshot.displayLabel, + displayLabel: accountSnapshot.accountEmail ?? "Account \(accountSnapshot.id.opaqueID)", usage: usage) payloads[payload.recordName] = payload } @@ -188,10 +188,14 @@ extension UsageStore { now: Date, previousEntry: WidgetSnapshot.ProviderEntry?) -> WidgetSnapshot.ProviderEntry? { - let snapshot = self.snapshots[provider.instanceID] + let claudeSwapAccount = provider == .claude + ? self.claudeSwapActiveAccountOverride(for: provider.instanceID) + : nil + let snapshot = claudeSwapAccount?.snapshot ?? self.snapshots[provider.instanceID] let storedTokenSnapshot = self.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot let expectedClaudeQuotaOwnerKey: String? = if provider == .claude { - self.expectedClaudeWidgetQuotaOwnerKey() + claudeSwapAccount.map(Self.claudeSwapWidgetQuotaOwnerKey) + ?? self.expectedClaudeWidgetQuotaOwnerKey() } else { nil } @@ -251,7 +255,11 @@ extension UsageStore { nil } let quotaOwnerKey: String? = if provider == .claude { - snapshot != nil ? self.liveClaudeWidgetQuotaOwnerKey() : preservedClaudeUsage?.quotaOwnerKey + if let claudeSwapAccount { + Self.claudeSwapWidgetQuotaOwnerKey(claudeSwapAccount) + } else { + snapshot != nil ? self.liveClaudeWidgetQuotaOwnerKey() : preservedClaudeUsage?.quotaOwnerKey + } } else { nil } @@ -280,6 +288,12 @@ extension UsageStore { let quotaOwnerKey: String? } + private nonisolated static func claudeSwapWidgetQuotaOwnerKey( + _ account: ProviderAccountUsageSnapshot) -> String + { + "\(account.id.source):\(account.id.opaqueID)" + } + private func expectedClaudeWidgetQuotaOwnerKey() -> String? { if let account = self.settings.effectiveSelectedTokenAccount(for: .claude) { return self.tokenAccountSnapshotCacheKey(provider: .claude, account: account) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 005569c7c..b71d7f295 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -60,6 +60,7 @@ extension UsageStore { _ = self.statuses _ = self.tokenSnapshotPublications _ = self.spendDashboardTokenPublications + _ = self.spendDashboardPublication.revision _ = self.historicalPaceRevision return 0 } @@ -188,6 +189,9 @@ final class UsageStore { var tokenSnapshotPublicationRevisions: [ProviderInstanceID: UInt64] = [:] var spendDashboardTokenPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:] var spendDashboardTokenPublicationRevisions: [ProviderInstanceID: UInt64] = [:] + var spendDashboardPublication = SpendDashboardPublication.empty + @ObservationIgnored var sharedSpendDashboardControllerStorage: SpendDashboardController? + @ObservationIgnored var sharedSpendDashboardObservationStarted = false var tokenErrors: [ProviderInstanceID: String] = [:] var tokenRefreshInFlight: Set = [] var codexCostCatchUpActivity: CodexCostCatchUpActivity? @@ -545,6 +549,7 @@ final class UsageStore { loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.hydrateCachedTokenSnapshots() + self.startSharedSpendDashboardPublication() self.detectVersions() self.updateProviderRuntimes() Task { @MainActor [weak self] in diff --git a/Sources/CodexBarCLI/CLIClaudeSwapCards.swift b/Sources/CodexBarCLI/CLIClaudeSwapCards.swift index a5a9f4cbf..840b5fcfd 100644 --- a/Sources/CodexBarCLI/CLIClaudeSwapCards.swift +++ b/Sources/CodexBarCLI/CLIClaudeSwapCards.swift @@ -127,13 +127,19 @@ enum CLIClaudeSwapCards { showSingleAccount: Bool = false, renderOptions: CLIClaudeSwapCardsRenderOptions, ambientFetch: @escaping AmbientFetch, - accountListReader: @escaping AccountListReader) async -> UsageCommandOutput + accountListReader: @escaping AccountListReader, + previousAccounts: [ProviderAccountUsageSnapshot] = []) async -> UsageCommandOutput { guard eligible else { return await ambientFetch() } do { let list = try await accountListReader(executablePath) - let accounts = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: renderOptions.now) + let retained = ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: previousAccounts) + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: retained, + now: renderOptions.now) + ClaudeSwapRetainedUsageStore.save(accounts) guard ClaudeSwapAccountProjection.shouldPresentAccounts( accountCount: accounts.count, showSingleAccount: showSingleAccount) diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index bcc3359c1..3fb73f4ff 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -90,11 +90,13 @@ struct DashboardSnapshotProducer: Sendable { generatedAt: generatedAt, refreshInterval: refreshInterval, codexBarVersion: codexBarVersion, + // Provider-specific by design: claude-swap is Claude-owned account data projected into the dashboard. claudeSwap: claudeSwap.map { DashboardClaudeSwapInput( accounts: $0.accounts, adapterError: $0.adapterError, - weeklyWorkDays: self.weeklyWorkDays()) + weeklyWorkDays: self.weeklyWorkDays(), + showSingleAccount: config.providerConfig(for: .claude)?.claudeSwapShowSingleAccount == true) }) return DashboardSnapshotResult( payload: payload, @@ -141,8 +143,12 @@ struct DashboardSnapshotProducer: Sendable { executablePath: path, timeout: timeout) } + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: ClaudeSwapRetainedUsageStore.load()) + ClaudeSwapRetainedUsageStore.save(accounts) return DashboardClaudeSwapCollection( - accounts: ClaudeSwapAccountProjection.accountSnapshots(from: list), + accounts: accounts, adapterError: nil) } catch { let diagnostic = CLIClaudeSwapText.sanitizeDiagnostic(error.localizedDescription) @@ -319,7 +325,9 @@ extension CodexBarCLI { } guard renamed == 0 else { throw Self.dashboardOutputPOSIXError(errno, path: url.path) } } catch { - if handleOpen { try? handle.close() } + if handleOpen { + try? handle.close() + } try? FileManager.default.removeItem(at: staged) throw error } diff --git a/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift b/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift index 641f751a9..3ba45f9ca 100644 --- a/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift +++ b/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift @@ -852,6 +852,13 @@ extension CLIServeWebUI { return dot; } + function visibleWindows(windows) { + // The producer marks a window idle when its whole model family reports no usage, which + // the page cannot work out on its own: a zero percentage also stands for a lane the + // provider never reported. Script clients still receive every window on the snapshot. + return (windows || []).filter(w => w.idle !== true); + } + function worstWindowLevel(windows) { const worst = Math.max(...(windows || []).map(w => finiteNumber(w.usedPercent)), -1); if (worst < 0) return null; @@ -881,7 +888,7 @@ extension CLIServeWebUI { if (account.active) { head.append(pill("active", "active")); } else { - const level = worstWindowLevel(account.windows); + const level = worstWindowLevel(visibleWindows(account.windows)); if (level) head.append(pill(level, level === "ok" ? "ok" : level === "warning" ? "high" : "critical")); } card.append(head); @@ -892,13 +899,14 @@ extension CLIServeWebUI { card.append(identity); } + // At-limit claude-swap cards carry both a deferred/limit note and retained + // windows; keep those bars visible instead of returning after the note. if (account.error) { card.append(node("p", "error-message", account.error)); - return card; } const windows = node("div", "windows"); - for (const window of account.windows || []) windows.append(renderWindow(window)); + for (const window of visibleWindows(account.windows)) windows.append(renderWindow(window)); card.append(windows); return card; } @@ -952,7 +960,7 @@ extension CLIServeWebUI { } const windows = node("div", "windows"); - for (const window of provider.windows || []) windows.append(renderWindow(window)); + for (const window of visibleWindows(provider.windows)) windows.append(renderWindow(window)); card.append(windows); if (provider.accountsError) { card.append(node("p", "error-message", provider.accountsError)); @@ -1022,6 +1030,11 @@ extension CLIServeWebUI { if (provider.accountsError) grid.append(node("p", "error-message", provider.accountsError)); group.append(grid); sections.push(group); + const activeAccount = accounts.find(account => account.active === true); + const activeHasUsableWindows = activeAccount && visibleWindows(activeAccount.windows).length > 0; + const hasAmbientSummary = visibleWindows(provider.windows).length > 0 || + provider.cost || provider.credits || provider.status; + if (!activeHasUsableWindows && hasAmbientSummary) rest.push(provider); } else { rest.push(provider); } diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 171d95402..502077796 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -513,6 +513,10 @@ extension CodexBarCLI { } #endif + // Provider-specific by design: Codex PAT User-Agent needs the CLI version before the fetch starts. + let resolvedCLIVersion = provider == .codex + ? Self.detectVersion(for: provider, browserDetection: command.browserDetection) + : nil let fetchContext = ProviderFetchContext( runtime: command.providerRuntime, sourceMode: effectiveSourceMode, @@ -530,7 +534,8 @@ extension CodexBarCLI { tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), providerManualTokenUpdater: tokenContext.manualTokenUpdater(), persistsCLISessions: Self.persistsCLISessions(provider: provider, command: command), - persistentCLISessionIdleWindow: command.persistentCLISessionIdleWindow) + persistentCLISessionIdleWindow: command.persistentCLISessionIdleWindow, + resolvedCLIVersion: resolvedCLIVersion) let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) if command.verbose, !command.jsonOnly { Self.printFetchAttempts(provider: provider, attempts: outcome.attempts) @@ -562,7 +567,8 @@ extension CodexBarCLI { let shouldDetectVersion = Self.shouldDetectVersion(provider: provider, result: result) let version = Self.normalizeVersion( raw: shouldDetectVersion - ? Self.detectVersion(for: provider, browserDetection: command.browserDetection) + ? (resolvedCLIVersion + ?? Self.detectVersion(for: provider, browserDetection: command.browserDetection)) : nil) let source = result.sourceLabel let notes = Self.usageTextNotes( diff --git a/Sources/CodexBarCLI/DashboardPayloads.swift b/Sources/CodexBarCLI/DashboardPayloads.swift index 8747498a7..7e3301217 100644 --- a/Sources/CodexBarCLI/DashboardPayloads.swift +++ b/Sources/CodexBarCLI/DashboardPayloads.swift @@ -206,6 +206,28 @@ struct DashboardWindowPayload: Encodable { let usedPercent: Double let remainingPercent: Double let resetAt: Date? + /// Whether a display client should skip this window. The producer sets it when the window belongs + /// to a model family that reports no usage at all, which a client cannot work out on its own + /// because a zero `usedPercent` also stands for a lane whose usage the provider never reported. + /// Additive schema-v1 extension: the key appears only when it is `true`, so every payload that + /// carries no idle window is byte-identical to the previous shape. + let idle: Bool + + init( + kind: String, + label: String, + usedPercent: Double, + remainingPercent: Double, + resetAt: Date?, + idle: Bool = false) + { + self.kind = kind + self.label = label + self.usedPercent = usedPercent + self.remainingPercent = remainingPercent + self.resetAt = resetAt + self.idle = idle + } private enum CodingKeys: String, CodingKey { case kind @@ -213,6 +235,7 @@ struct DashboardWindowPayload: Encodable { case usedPercent case remainingPercent case resetAt + case idle } func encode(to encoder: Encoder) throws { @@ -222,6 +245,9 @@ struct DashboardWindowPayload: Encodable { try container.encode(self.usedPercent, forKey: .usedPercent) try container.encode(self.remainingPercent, forKey: .remainingPercent) try container.encode(self.resetAt, forKey: .resetAt) + if self.idle { + try container.encode(true, forKey: .idle) + } } } diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift index 6495e886c..2a1716803 100644 --- a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -5,6 +5,19 @@ struct DashboardClaudeSwapInput { let accounts: [ProviderAccountUsageSnapshot]? let adapterError: String? let weeklyWorkDays: Int? + let showSingleAccount: Bool + + init( + accounts: [ProviderAccountUsageSnapshot]?, + adapterError: String?, + weeklyWorkDays: Int?, + showSingleAccount: Bool = false) + { + self.accounts = accounts + self.adapterError = adapterError + self.weeklyWorkDays = weeklyWorkDays + self.showSingleAccount = showSingleAccount + } } /// Projects the CLI's provider usage and cost payloads into the stable, @@ -120,8 +133,19 @@ enum DashboardSnapshotBuilder { let metadata = descriptor?.metadata let error = payload.error ?? cost?.error + let projectedAccounts: [ProviderAccountUsageSnapshot]? = if let claudeSwapAccounts = claudeSwap?.accounts, + claudeSwapAccounts.isEmpty || + ClaudeSwapAccountProjection.shouldPresentAccounts( + accountCount: claudeSwapAccounts.count, + showSingleAccount: claudeSwap? + .showSingleAccount == true) + { + claudeSwapAccounts + } else { + nil + } let accounts = claudeSwap?.adapterError == nil - ? claudeSwap?.accounts?.map { account in + ? projectedAccounts?.map { account in self.makeClaudeSwapAccount( account, identityMode: identityMode, @@ -179,20 +203,34 @@ enum DashboardSnapshotBuilder { weeklyWorkDays: Int?, generatedAt: Date) -> DashboardAccountPayload { - // Provider-specific by design: claude-swap keeps the source email in displayLabel when usage is unavailable. - let snapshotEmail = account.snapshot?.identity?.accountEmail - let email = snapshotEmail?.contains("@") == true - ? snapshotEmail - : (account.displayLabel.contains("@") ? account.displayLabel : nil) - let presentedEmail = identityMode != .none && email?.contains("@") == true - ? self.dashboardEmail(email, mode: identityMode) + // Provider-specific by design: identity stays the source email. Aliases and organization labels can + // contain personal or workspace-identifying text, so redacted output retains only the redacted mailbox. + let sourceEmail: String? = { + if let email = account.accountEmail, email.contains("@") { + return email + } + if let email = account.snapshot?.identity?.accountEmail, email.contains("@") { + return email + } + return nil + }() + let presentedEmail = identityMode != .none && sourceEmail?.contains("@") == true + ? self.dashboardEmail(sourceEmail, mode: identityMode) : nil let identity = presentedEmail.map { DashboardIdentityPayload(accountEmail: $0, plan: nil) } + let trimmedLabel = account.displayLabel.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackLabel = trimmedLabel.isEmpty ? "Account \(account.id.opaqueID)" : trimmedLabel + let label = self.claudeSwapDashboardLabel( + displayLabel: fallbackLabel, + sourceEmail: sourceEmail, + presentedEmail: presentedEmail, + accountID: account.id.opaqueID, + identityMode: identityMode) // Provider-specific by design: claude-swap account windows and pace use Claude's presentation semantics. let metadata = ProviderDescriptorRegistry.descriptor(for: UsageProvider.claude).metadata return DashboardAccountPayload( id: "\(account.id.source):\(account.id.opaqueID)", - label: presentedEmail ?? "Account \(account.id.opaqueID)", + label: label, active: account.isActive, identity: identity, windows: self.makeWindows(provider: .claude, metadata: metadata, usage: account.snapshot), @@ -207,6 +245,32 @@ enum DashboardSnapshotBuilder { updatedAt: account.snapshot?.updatedAt) } + private static func claudeSwapDashboardLabel( + displayLabel: String, + sourceEmail: String?, + presentedEmail: String?, + accountID: String, + identityMode: DashboardIdentityMode) -> String + { + switch identityMode { + case .full: + return displayLabel + case .none: + return "Account \(accountID)" + case .redacted: + guard let presentedEmail, let sourceEmail else { + return "Account \(accountID)" + } + if displayLabel == sourceEmail { + return presentedEmail + } + if displayLabel.hasPrefix(sourceEmail) { + return "\(presentedEmail) · Account \(accountID)" + } + return "Account \(accountID)" + } + } + private static func dashboardSource(from source: String) -> String { let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "unknown" : trimmed @@ -320,14 +384,23 @@ enum DashboardSnapshotBuilder { } /// Display lanes for an Antigravity quota-summary snapshot, or `nil` when the snapshot has no - /// summary lanes and must keep the standard primary and secondary rows. Every family stays - /// visible: the dashboard is a detail surface, so only the duplicated representatives go away. + /// summary lanes and must keep the standard primary and secondary rows. Every family stays in the + /// payload, because a script client reads the same document and must not lose a window. The lanes of + /// a family that reports no usage carry `idle`, the same rule the menu card and the widget use to + /// hide that family, so the web UI can drop those rows without repeating the rule in JavaScript. private static func antigravityQuotaSummaryWindows(_ usage: UsageSnapshot) -> [DashboardWindowPayload]? { let extras = usage.extraRateWindows ?? [] guard extras.contains(where: { AntigravityStatusSnapshot.isQuotaSummaryWindowID($0.id) }) else { return nil } - return extras.map { self.makeWindow(kind: $0.id, label: $0.title, window: $0.window) } + let idleWindowIDs = AntigravityQuotaFamilyVisibility.idleWindowIDs(in: usage) + return extras.map { + self.makeWindow( + kind: $0.id, + label: $0.title, + window: $0.window, + idle: idleWindowIDs.contains($0.id)) + } } private struct RateWindowLabels { @@ -355,7 +428,12 @@ enum DashboardSnapshotBuilder { tertiary: labels.tertiary) } - private static func makeWindow(kind: String, label: String, window: RateWindow) -> DashboardWindowPayload { + private static func makeWindow( + kind: String, + label: String, + window: RateWindow, + idle: Bool = false) -> DashboardWindowPayload + { let used = self.clampedPercent(window.usedPercent) let remaining = self.clampedPercent(100 - used) return DashboardWindowPayload( @@ -363,7 +441,8 @@ enum DashboardSnapshotBuilder { label: label, usedPercent: used, remainingPercent: remaining, - resetAt: window.resetsAt) + resetAt: window.resetsAt, + idle: idle) } private static func clampedPercent(_ value: Double) -> Double { diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 8797bfe54..50e8be9a6 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "d82510ddf51fc013" + static let value = "0370dcb97ac7bb79" } diff --git a/Sources/CodexBarCore/Host/Process/RPCChildProcessTeardown.swift b/Sources/CodexBarCore/Host/Process/RPCChildProcessTeardown.swift index 431039324..f86057407 100644 --- a/Sources/CodexBarCore/Host/Process/RPCChildProcessTeardown.swift +++ b/Sources/CodexBarCore/Host/Process/RPCChildProcessTeardown.swift @@ -1,5 +1,39 @@ +#if canImport(Darwin) +import Darwin +#endif import Foundation +package final class RPCChildProcessInput: @unchecked Sendable { + package let pipe = Pipe() + + private let lock = NSLock() + private var isClosed = false + + package init() { + #if canImport(Darwin) + // Keep broken pipes catchable instead of terminating the app with SIGPIPE. + _ = fcntl(self.pipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) + #endif + } + + package func write(_ data: Data) throws { + try self.lock.withLock { + guard !self.isClosed else { + throw CocoaError(.fileWriteUnknown) + } + try self.pipe.fileHandleForWriting.write(contentsOf: data) + } + } + + package func close() { + self.lock.withLock { + guard !self.isClosed else { return } + self.isClosed = true + try? self.pipe.fileHandleForWriting.close() + } + } +} + package enum RPCChildProcessTeardown { /// Tears down a JSON-RPC child spawned via Foundation `Process`. /// @@ -7,8 +41,8 @@ package enum RPCChildProcessTeardown { /// then escalates SIGTERM -> bounded wait -> SIGKILL across the child's process tree via /// `SubprocessRunner.terminateProcess`, so children that ignore SIGTERM cannot leak /// (#2789). Foundation reaps the child once it exits, so no explicit waitpid is needed here. - package static func terminate(process: Process, stdinPipe: Pipe) { - try? stdinPipe.fileHandleForWriting.close() + package static func terminate(process: Process, stdin: RPCChildProcessInput) { + stdin.close() SubprocessRunner.terminateProcess(process, processGroup: nil) } } diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 3a99d2cff..3930c4823 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -595,7 +595,7 @@ public enum CodexLaunchPreflight { bytes == [0xCA, 0xFE, 0xBA, 0xBF] } - private static func spctlAssessment(path: String, timeout: TimeInterval = 2.0) -> GatekeeperAssessment? { + private static func spctlAssessment(path: String, timeout: TimeInterval = 5.0) -> GatekeeperAssessment? { let spctlPath = "/usr/sbin/spctl" guard FileManager.default.isExecutableFile(atPath: spctlPath) else { return nil } diff --git a/Sources/CodexBarCore/ProviderAccountSnapshot.swift b/Sources/CodexBarCore/ProviderAccountSnapshot.swift index 6a569c1b6..fd10eb5f1 100644 --- a/Sources/CodexBarCore/ProviderAccountSnapshot.swift +++ b/Sources/CodexBarCore/ProviderAccountSnapshot.swift @@ -25,6 +25,9 @@ public struct ProviderAccountUsageSnapshot: Identifiable, Sendable { /// Display-only label (may contain personal data such as an email); UI is /// responsible for privacy redaction. Never logged or persisted. public let displayLabel: String + /// Display-only source email, kept separate from `displayLabel` so aliases and + /// `email · org` disambiguation cannot leak into identity. + public let accountEmail: String? public let isActive: Bool /// Whether the source can make this inactive account the provider's active account. /// Activation remains source-owned; CodexBar never handles credential material. @@ -37,6 +40,7 @@ public struct ProviderAccountUsageSnapshot: Identifiable, Sendable { id: ProviderAccountIdentity, provider: UsageProvider, displayLabel: String, + accountEmail: String? = nil, isActive: Bool, canActivate: Bool = false, snapshot: UsageSnapshot?, @@ -46,6 +50,7 @@ public struct ProviderAccountUsageSnapshot: Identifiable, Sendable { self.id = id self.provider = provider self.displayLabel = displayLabel + self.accountEmail = accountEmail self.isActive = isActive self.canActivate = canActivate self.snapshot = snapshot diff --git a/Sources/CodexBarCore/ProviderHTTPClient.swift b/Sources/CodexBarCore/ProviderHTTPClient.swift index 68b8b383e..dfbf2d7f0 100644 --- a/Sources/CodexBarCore/ProviderHTTPClient.swift +++ b/Sources/CodexBarCore/ProviderHTTPClient.swift @@ -201,7 +201,7 @@ public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendabl delegateQueue: nil) } - private static var isRunningTests: Bool { + static var isRunningTests: Bool { let environment = ProcessInfo.processInfo.environment if environment["XCTestConfigurationFilePath"] != nil || environment["XCTestBundlePath"] != nil { return true diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 49e5afff2..7f8a92d35 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -540,6 +540,17 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { request.setValue( "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "Accept") + // The OneConsole shell only server-renders `window.ALIYUN_CONSOLE_CONFIG.SEC_TOKEN` for a + // genuine same-origin document navigation; a bare request receives a token-less shell, so the + // Personal `sec_token` can never be scraped. Send the browser-navigation headers so the shell + // includes it (mainland Personal/Solo rejects the API without it — fixes #2500/#2349/#2370). + if let origin = request.url.flatMap(\.host).map({ "https://\($0)/" }) { + request.setValue(origin, forHTTPHeaderField: "Referer") + } + request.setValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site") + request.setValue("navigate", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("document", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("zh-CN,zh;q=0.9,en;q=0.8", forHTTPHeaderField: "Accept-Language") if let (data, response) = try? await session.data(for: request), let httpResponse = response as? HTTPURLResponse, @@ -1257,12 +1268,15 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return names.isEmpty ? "none" : names.joined(separator: ",") } - private static func extractSECToken(from html: String) -> String? { + static func extractSECToken(from html: String) -> String? { let patterns = [ #""secToken"\s*:\s*"([^"]+)""#, #""sec_token"\s*:\s*"([^"]+)""#, #"secToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, #"sec_token['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + // Aliyun's OneConsole shell embeds it inside `window.ALIYUN_CONSOLE_CONFIG` with an + // upper-case, unquoted key: `SEC_TOKEN: ""`. The lower-case patterns above miss it. + #"SEC_TOKEN['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, ] for pattern in patterns { if let token = self.matchFirstGroup(pattern: pattern, in: html), !token.isEmpty { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift index 664ea890b..65ceecfca 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift @@ -4,7 +4,8 @@ import Foundation /// Strictly parsed result of `cswap --list --json` (schema v1). /// /// Only the fields allow-listed in `docs/claude-multi-account-and-status-items.md` -/// are decoded: slot number, display email, active state, usage status, and the +/// are decoded: slot number, display email, display-only `organizationName`, +/// optional display-only `alias`, active state, usage status, and the /// 5-hour/7-day windows and optional model-scoped weekly windows (percent + /// reset timestamp). Everything else in the payload is ignored; unknown schema /// versions and partial top-level shapes are rejected. @@ -22,6 +23,12 @@ public struct ClaudeSwapAccountRow: Equatable, Sendable { public let number: Int /// Display-only sensitive value; never logged or persisted. public let email: String + /// Display-only workspace name from cswap schema v1; always present, may be empty. + /// Never identity, never logged, never persisted. + public let organizationName: String + /// Display-only user-chosen cswap alias; omitted unless non-empty. + /// Never identity, never logged, never persisted. + public let alias: String? public let isActive: Bool public let usageStatus: ClaudeSwapUsageStatus public let fiveHour: ClaudeSwapUsageWindow? @@ -31,6 +38,8 @@ public struct ClaudeSwapAccountRow: Equatable, Sendable { public init( number: Int, email: String, + organizationName: String = "", + alias: String? = nil, isActive: Bool, usageStatus: ClaudeSwapUsageStatus, fiveHour: ClaudeSwapUsageWindow?, @@ -39,6 +48,8 @@ public struct ClaudeSwapAccountRow: Equatable, Sendable { { self.number = number self.email = email + self.organizationName = organizationName + self.alias = alias self.isActive = isActive self.usageStatus = usageStatus self.fiveHour = fiveHour @@ -187,10 +198,15 @@ public enum ClaudeSwapListParser { throw ClaudeSwapListParserError.malformedShape("slot \(number) has no usageStatus") } let email = (row["email"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let organizationName = (row["organizationName"] as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let alias = Self.nonEmptyDisplayString(row["alias"]) let usage = row["usage"] as? [String: Any] return try ClaudeSwapAccountRow( number: number, email: email, + organizationName: organizationName, + alias: alias, isActive: isActive, usageStatus: ClaudeSwapUsageStatus(rawValue: rawStatus), fiveHour: self.parseWindow(usage?["fiveHour"], slot: number, name: "fiveHour"), @@ -239,6 +255,14 @@ public enum ClaudeSwapListParser { } } + /// Display-only optional string. Missing, non-string, and whitespace-only values are omitted. + private static func nonEmptyDisplayString(_ raw: Any?) -> String? { + guard let text = (raw as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + return text + } + private static func finiteDouble(_ raw: Any?) -> Double? { guard let number = raw as? NSNumber else { return nil } // JSON booleans bridge to NSNumber too; only accept genuine numbers. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift index 439caebe0..acad1db4e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift @@ -8,6 +8,8 @@ public enum ClaudeSwapAccountProjection { public static let sourceLabel = "claude-swap" static let fiveHourWindowMinutes = 5 * 60 static let sevenDayWindowMinutes = 7 * 24 * 60 + static let exhaustedUsedPercent = 100.0 + static let deferredPollingNote = "Polling deferred until a limit resets." public static func shouldPresentAccounts(accountCount: Int, showSingleAccount: Bool) -> Bool { accountCount >= (showSingleAccount ? 1 : 2) @@ -15,23 +17,35 @@ public enum ClaudeSwapAccountProjection { public static func accountSnapshots( from list: ClaudeSwapAccountList, + previousAccounts: [ProviderAccountUsageSnapshot] = [], now: Date = Date()) -> [ProviderAccountUsageSnapshot] { + let previousByID = Dictionary( + previousAccounts.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first }) let ordered = list.accounts.sorted { lhs, rhs in if lhs.isActive != rhs.isActive { return lhs.isActive } return lhs.number < rhs.number } - return ordered.map { row in - ProviderAccountUsageSnapshot( - id: ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number)), + let duplicateEmails = self.duplicateEmails(in: ordered) + let labels = self.displayLabels(for: ordered, duplicateEmails: duplicateEmails) + return zip(ordered, labels).map { row, label in + let id = ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number)) + let snapshot = self.usageSnapshot( + for: row, + previous: previousByID[id], + now: now) + return ProviderAccountUsageSnapshot( + id: id, provider: .claude, - displayLabel: self.displayLabel(for: row), + displayLabel: label, + accountEmail: row.email.isEmpty ? nil : row.email, isActive: row.isActive, canActivate: !row.isActive && self.canActivate(row), - snapshot: self.usageSnapshot(for: row, now: now), - error: self.errorText(for: row), + snapshot: snapshot, + error: self.errorText(for: row, snapshot: snapshot, now: now), sourceLabel: self.sourceLabel) } } @@ -46,12 +60,140 @@ public enum ClaudeSwapAccountProjection { ?? adapterError.map { "Showing the last successful update: \($0)" } } - static func displayLabel(for row: ClaudeSwapAccountRow) -> String { - row.email.isEmpty ? "Account \(row.number)" : row.email + static func displayLabel(for row: ClaudeSwapAccountRow, duplicateEmails: Set = []) -> String { + if let alias = self.alias(from: row) { + return alias + } + if row.email.isEmpty { + return "Account \(row.number)" + } + guard duplicateEmails.contains(self.normalizedEmail(row.email)) else { + return row.email + } + if !row.organizationName.isEmpty { + return "\(row.email) · \(row.organizationName)" + } + return "\(row.email) · Account \(row.number)" + } + + private static func displayLabels(for rows: [ClaudeSwapAccountRow], duplicateEmails: Set) -> [String] { + let candidates = rows.map { self.displayLabel(for: $0, duplicateEmails: duplicateEmails) } + var collisionCounts: [String: Int] = [:] + for label in candidates { + collisionCounts[self.normalizedLabel(label), default: 0] += 1 + } + return zip(rows, candidates).map { row, label in + guard collisionCounts[self.normalizedLabel(label), default: 0] > 1 else { + return label + } + return "\(label) · Account \(row.number)" + } + } + + private static func normalizedLabel(_ label: String) -> String { + label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func alias(from row: ClaudeSwapAccountRow) -> String? { + guard let alias = row.alias?.trimmingCharacters(in: .whitespacesAndNewlines), !alias.isEmpty else { + return nil + } + return alias + } + + private static func duplicateEmails(in rows: [ClaudeSwapAccountRow]) -> Set { + var counts: [String: Int] = [:] + for email in rows.map(\.email) { + let normalized = self.normalizedEmail(email) + guard !normalized.isEmpty else { continue } + counts[normalized, default: 0] += 1 + } + return Set(counts.compactMap { $0.value > 1 ? $0.key : nil }) + } + + private static func normalizedEmail(_ email: String) -> String { + email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func usageSnapshot( + for row: ClaudeSwapAccountRow, + previous: ProviderAccountUsageSnapshot?, + now: Date) -> UsageSnapshot? + { + switch row.usageStatus { + case .ok, .unavailable: + if let projected = self.projectedUsageSnapshot(for: row, now: now) { + if row.usageStatus == .ok { + return projected + } + if let pruned = self.prunedAtLimitSnapshot( + projected, + identity: projected.identity ?? self.identitySnapshot(for: row), + now: now) + { + return pruned + } + } + guard row.usageStatus == .unavailable else { return nil } + return self.retainedAtLimitSnapshot(previous, matching: row, now: now) + case .tokenExpired, .reloginRequired, .apiKey, .keychainUnavailable, .noCredentials, .unknown: + return nil + } + } + + private static func retainedAtLimitSnapshot( + _ previous: ProviderAccountUsageSnapshot?, + matching row: ClaudeSwapAccountRow, + now: Date) -> UsageSnapshot? + { + guard let previous, let snapshot = previous.snapshot else { return nil } + let previousFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(from: previous) + let rowFingerprint = ClaudeSwapRetainedUsageStore.fingerprint( + email: row.email, + slot: String(row.number)) + guard let previousFingerprint, let rowFingerprint, previousFingerprint == rowFingerprint else { + return nil + } + return self.prunedAtLimitSnapshot(snapshot, identity: self.identitySnapshot(for: row), now: now) + } + + /// Drops windows whose reset is in the past so a mixed snapshot cannot keep showing + /// an already-reset lane as "Resets now" just because a sibling is still exhausted. + private static func prunedAtLimitSnapshot( + _ snapshot: UsageSnapshot, + identity: ProviderIdentitySnapshot?, + now: Date) -> UsageSnapshot? + { + let primary = self.unexpiredWindow(snapshot.primary, now: now) + let secondary = self.unexpiredWindow(snapshot.secondary, now: now) + let extra = (snapshot.extraRateWindows ?? []).compactMap { named -> NamedRateWindow? in + guard let window = self.unexpiredWindow(named.window, now: now) else { return nil } + return NamedRateWindow( + id: named.id, + title: named.title, + window: window, + usageKnown: named.usageKnown) + } + let remaining = [primary, secondary].compactMap(\.self) + extra.map(\.window) + guard remaining.contains(where: { $0.usedPercent >= self.exhaustedUsedPercent }) else { + return nil + } + return UsageSnapshot( + primary: primary, + secondary: secondary, + extraRateWindows: extra.isEmpty ? nil : extra, + updatedAt: snapshot.updatedAt, + identity: identity, + dataConfidence: snapshot.dataConfidence) + } + + private static func unexpiredWindow(_ window: RateWindow?, now: Date) -> RateWindow? { + guard let window else { return nil } + guard let resetsAt = window.resetsAt, resetsAt > now else { return nil } + return window } - private static func usageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? { - guard row.usageStatus == .ok else { return nil } + private static func projectedUsageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? { let primary = row.fiveHour.map { window in RateWindow( usedPercent: window.usedPercent, @@ -73,11 +215,16 @@ public enum ClaudeSwapAccountProjection { secondary: secondary, extraRateWindows: scoped.isEmpty ? nil : scoped, updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: self.displayLabel(for: row), - accountOrganization: nil, - loginMethod: self.sourceLabel)) + identity: self.identitySnapshot(for: row)) + } + + private static func identitySnapshot(for row: ClaudeSwapAccountRow) -> ProviderIdentitySnapshot { + ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: row.email.isEmpty ? nil : row.email, + accountOrganization: nil, + loginMethod: self.sourceLabel, + accountID: "\(self.sourceName):\(row.number)") } private static func scopedRateWindows(for row: ClaudeSwapAccountRow) -> [NamedRateWindow] { @@ -92,12 +239,10 @@ public enum ClaudeSwapAccountProjection { }) } - private static func errorText(for row: ClaudeSwapAccountRow) -> String? { + private static func errorText(for row: ClaudeSwapAccountRow, snapshot: UsageSnapshot?, now: Date) -> String? { switch row.usageStatus { case .ok: - row.fiveHour == nil && row.sevenDay == nil && self.scopedRateWindows(for: row).isEmpty - ? "No usage windows reported." - : nil + snapshot == nil ? "No usage windows reported." : nil case .tokenExpired: "Token expired. Switch to this account in claude-swap to refresh it." case .reloginRequired: @@ -109,12 +254,48 @@ public enum ClaudeSwapAccountProjection { case .noCredentials: "No stored credentials for this account slot." case .unavailable: - "Usage fetch failed." + self.atLimitNote(from: snapshot, now: now) ?? self.deferredPollingNote case let .unknown(raw): "Unrecognized claude-swap status: \(raw)" } } + private static func atLimitNote(from snapshot: UsageSnapshot?, now: Date) -> String? { + guard let snapshot else { return nil } + var parts: [String] = [] + if let primary = snapshot.primary { + self.appendLimit(named: "Session", window: primary, now: now, to: &parts) + } + if let secondary = snapshot.secondary { + self.appendLimit(named: "Weekly", window: secondary, now: now, to: &parts) + } + for extra in snapshot.extraRateWindows ?? [] { + self.appendLimit(named: self.scopedLimitName(extra.title), window: extra.window, now: now, to: &parts) + } + guard !parts.isEmpty else { return nil } + return parts.joined(separator: " ") + } + + private static func appendLimit( + named name: String, + window: RateWindow, + now: Date, + to parts: inout [String]) + { + guard window.usedPercent >= self.exhaustedUsedPercent else { return } + if let reset = UsageFormatter.resetLine(for: window, style: .countdown, now: now) { + parts.append("\(name) limit reached. \(reset).") + } else { + parts.append("\(name) limit reached.") + } + } + + private static func scopedLimitName(_ title: String) -> String { + let suffix = " only" + guard title.hasSuffix(suffix) else { return title } + return String(title.dropLast(suffix.count)) + } + private static func canActivate(_ row: ClaudeSwapAccountRow) -> Bool { switch row.usageStatus { case .ok, .apiKey, .unavailable: diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapRetainedUsageStore.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapRetainedUsageStore.swift new file mode 100644 index 000000000..91b6fc23e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapRetainedUsageStore.swift @@ -0,0 +1,142 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +/// Slot-keyed usage windows from the last successful Claude Swap projection. +/// Display labels and emails stay out of the cache so one-shot CLI/dashboard +/// calls can retain at-limit bars without persisting identity. A SHA-256 +/// fingerprint binds those windows to the account that produced them. +public enum ClaudeSwapRetainedUsageStore { + private static let fingerprintPrefix = "fp:" + + public static func load() -> [ProviderAccountUsageSnapshot] { + guard let url = self.resolvedFileURL(), + let data = try? Data(contentsOf: url), + let records = try? JSONDecoder().decode([Record].self, from: data) + else { return [] } + return records.map(\.account) + } + + /// After a relaunch the in-memory array is empty even when this cache still + /// holds complete windows, so fall back to disk only when nothing is in memory. + public static func previousAccounts( + inMemory: [ProviderAccountUsageSnapshot]) -> [ProviderAccountUsageSnapshot] + { + inMemory.isEmpty ? self.load() : inMemory + } + + public static func save(_ accounts: [ProviderAccountUsageSnapshot]) { + guard let url = self.resolvedFileURL() else { return } + let records = accounts.compactMap(Record.init(account:)) + guard let data = try? JSONEncoder().encode(records) else { return } + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try? data.write(to: url, options: .atomic) + } + + /// In-memory equivalent of save/load, used to prove cache-shaped previous snapshots + /// still reject a different account in the same slot. + static func snapshotsForRetention( + _ accounts: [ProviderAccountUsageSnapshot]) -> [ProviderAccountUsageSnapshot] + { + accounts.compactMap(Record.init(account:)).map(\.account) + } + + static func fingerprint(email: String, slot: String) -> String? { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard trimmed.contains("@") else { return nil } + let material = "\(slot)\u{0}\(trimmed)" + return SHA256.hash(data: Data(material.utf8)).map { String(format: "%02x", $0) }.joined() + } + + static func fingerprint(from account: ProviderAccountUsageSnapshot) -> String? { + if let stored = account.snapshot?.identity?.accountID, + stored.hasPrefix(self.fingerprintPrefix) + { + return String(stored.dropFirst(self.fingerprintPrefix.count)) + } + let email = account.snapshot?.identity?.accountEmail ?? account.accountEmail + guard let email, !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return self.fingerprint(email: email, slot: account.id.opaqueID) + } + + static func fingerprintAccountID(_ fingerprint: String) -> String { + self.fingerprintPrefix + fingerprint + } + + private static func resolvedFileURL() -> URL? { + if self.isRunningTests { return nil } + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + return base? + .appendingPathComponent("QuotaKit", isDirectory: true) + .appendingPathComponent("claude-swap-retained-usage.json") + } + + private static var isRunningTests: Bool { + let environment = ProcessInfo.processInfo.environment + if environment["XCTestConfigurationFilePath"] != nil || environment["XCTestBundlePath"] != nil { + return true + } + if ProcessInfo.processInfo.processName.lowercased().contains("xctest") { + return true + } + return CommandLine.arguments.contains { $0.lowercased().contains(".xctest") } + } + + private struct Record: Codable { + var opaqueID: String + var accountFingerprint: String + var primary: RateWindow? + var secondary: RateWindow? + var extraRateWindows: [NamedRateWindow]? + var updatedAt: Date + + init?(account: ProviderAccountUsageSnapshot) { + guard account.id.source == ClaudeSwapAccountProjection.sourceName, + let snapshot = account.snapshot + else { return nil } + self.opaqueID = account.id.opaqueID + guard let email = snapshot.identity?.accountEmail ?? account.accountEmail, + let fingerprint = ClaudeSwapRetainedUsageStore.fingerprint( + email: email, + slot: account.id.opaqueID) + else { + return nil + } + self.accountFingerprint = fingerprint + self.primary = snapshot.primary + self.secondary = snapshot.secondary + self.extraRateWindows = snapshot.extraRateWindows + self.updatedAt = snapshot.updatedAt + } + + var account: ProviderAccountUsageSnapshot { + ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity( + source: ClaudeSwapAccountProjection.sourceName, + opaqueID: self.opaqueID), + provider: .claude, + displayLabel: "", + isActive: false, + snapshot: UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + extraRateWindows: self.extraRateWindows, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: ClaudeSwapAccountProjection.sourceLabel, + accountID: ClaudeSwapRetainedUsageStore.fingerprintAccountID(self.accountFingerprint))), + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index ed4634d67..0ef71e05e 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -1,3 +1,5 @@ +import Foundation + #if canImport(Darwin) import Darwin #elseif canImport(Glibc) @@ -5,7 +7,6 @@ import Glibc #elseif canImport(Musl) import Musl #endif -import Foundation public enum CodexOAuthCredentialSource: String, Equatable, Sendable { case codexHome @@ -94,24 +95,28 @@ public enum CodexOAuthCredentialsStore { fileManager: FileManager = .default, homeDirectory: URL? = nil) -> URL { - let home = if self.nonEmpty(env["CODEX_HOME"]) != nil { - CodexHomeScope.ambientHomeURL(env: env, fileManager: fileManager) - } else { - (homeDirectory ?? fileManager.homeDirectoryForCurrentUser) - .appendingPathComponent(".codex", isDirectory: true) - } - return home - .appendingPathComponent("auth.json") + let home = + if self.nonEmpty(env["CODEX_HOME"]) != nil { + CodexHomeScope.ambientHomeURL(env: env, fileManager: fileManager) + } else { + (homeDirectory ?? fileManager.homeDirectoryForCurrentUser) + .appendingPathComponent(".codex", isDirectory: true) + } + return + home + .appendingPathComponent("auth.json") } - public static func load(env: [String: String] = ProcessInfo.processInfo - .environment) throws -> CodexOAuthCredentials + public static func load( + env: [String: String] = ProcessInfo.processInfo + .environment) throws -> CodexOAuthCredentials { try self.loadNative(env: env, homeDirectory: nil) } - public static func loadOAuthTokens(env: [String: String] = ProcessInfo.processInfo - .environment) throws -> CodexOAuthCredentials + public static func loadOAuthTokens( + env: [String: String] = ProcessInfo.processInfo + .environment) throws -> CodexOAuthCredentials { try self.parseOAuthTokens(data: self.readAuthData(env: env), source: .codexHome) } @@ -120,6 +125,24 @@ public enum CodexOAuthCredentialsStore { try self.parse(data: data, source: .codexHome) } + public static func loadPAT( + env: [String: String] = ProcessInfo.processInfo.environment) throws -> CodexPATCredentials + { + try self.parsePAT(data: self.readAuthData(env: env), source: .codexHome) + } + + /// Load a PAT from the scoped `CODEX_HOME` when that home has one, otherwise from ambient + /// `~/.codex`. Managed and fail-closed homes always use ambient. + public static func loadPATResolvingScopedHome( + env: [String: String] = ProcessInfo.processInfo.environment) throws -> CodexPATCredentials + { + try self.loadPAT(env: CodexPATFetchStrategy.credentialEnvironment(env)) + } + + public static func parsePAT(data: Data) throws -> CodexPATCredentials { + try self.parsePAT(data: data, source: .codexHome) + } + /// Resolve a credential for a usage probe without changing any source file. /// /// The ambient Codex home wins. External sources are opt-in because reading another @@ -177,6 +200,28 @@ public enum CodexOAuthCredentialsStore { throw CodexOAuthCredentialsError.missingTokens } + private static func parsePAT( + data: Data, + source: CodexOAuthCredentialSource) throws -> CodexPATCredentials + { + let json = try self.decodeObject(data: data) + guard let credentials = self.patCredentials(in: json, source: source) else { + throw CodexOAuthCredentialsError.missingTokens + } + return credentials + } + + private static func patCredentials( + in json: [String: Any], + source: CodexOAuthCredentialSource) -> CodexPATCredentials? + { + let token = + self.nonEmpty(json["personal_access_token"] as? String) + ?? self.nonEmpty(json["personalAccessToken"] as? String) + guard let token else { return nil } + return CodexPATCredentials(token: token, source: source) + } + private static func readAuthData( env: [String: String], fileManager: FileManager = .default, @@ -194,9 +239,9 @@ public enum CodexOAuthCredentialsStore { return try Data(contentsOf: url, options: [.mappedIfSafe]) } catch { let nsError = error as NSError - let missingFile = (nsError.domain == NSCocoaErrorDomain && - nsError.code == CocoaError.fileReadNoSuchFile.rawValue) || - (nsError.domain == NSPOSIXErrorDomain && nsError.code == ENOENT) + let missingFile = + (nsError.domain == NSCocoaErrorDomain && nsError.code == CocoaError.fileReadNoSuchFile.rawValue) + || (nsError.domain == NSPOSIXErrorDomain && nsError.code == ENOENT) throw missingFile ? CodexOAuthCredentialsError.notFound : CodexOAuthCredentialsError.unreadable } } @@ -233,8 +278,9 @@ public enum CodexOAuthCredentialsStore { } let idToken = Self.stringValue(in: tokens, snakeCaseKey: "id_token", camelCaseKey: "idToken") - let accountId = Self.nonEmpty( - Self.stringValue(in: tokens, snakeCaseKey: "account_id", camelCaseKey: "accountId")) + let accountId = + Self.nonEmpty( + Self.stringValue(in: tokens, snakeCaseKey: "account_id", camelCaseKey: "accountId")) ?? Self.accountIDFromJWT(idToken: idToken, accessToken: accessToken) let lastRefresh = Self.parseLastRefresh(from: json["last_refresh"]) @@ -300,7 +346,8 @@ public enum CodexOAuthCredentialsStore { json["last_refresh"] = ISO8601DateFormatter().string(from: lastRefresh) } - let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + let data = try JSONSerialization.data( + withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) let directory = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try CredentialFileWriter.writePrivate(data, to: url) @@ -326,10 +373,11 @@ public enum CodexOAuthCredentialsStore { homeDirectory: URL? = nil) throws -> CodexOAuthCredentials { let home = homeDirectory ?? fileManager.homeDirectoryForCurrentUser - let url = home - .appendingPathComponent(".config", isDirectory: true) - .appendingPathComponent("codex", isDirectory: true) - .appendingPathComponent("auth.json") + let url = + home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codex", isDirectory: true) + .appendingPathComponent("auth.json") return try self.parseOAuthTokens( data: self.readAuthData(at: url), source: .legacyCodexHome) @@ -351,19 +399,20 @@ public enum CodexOAuthCredentialsStore { fileManager: FileManager = .default, homeDirectory: URL? = nil) throws -> CodexOAuthCredentials { - let root: URL = if let configured = self.nonEmpty(env["XDG_DATA_HOME"]), - let normalized = CodexHomeScope.normalizedHomePath(configured, fileManager: fileManager) - { - URL(fileURLWithPath: normalized, isDirectory: true) - } else { - (homeDirectory ?? fileManager.homeDirectoryForCurrentUser) - .appendingPathComponent(".local", isDirectory: true) - .appendingPathComponent("share", isDirectory: true) - } + let root: URL = + if let configured = self.nonEmpty(env["XDG_DATA_HOME"]), + let normalized = CodexHomeScope.normalizedHomePath(configured, fileManager: fileManager) { + URL(fileURLWithPath: normalized, isDirectory: true) + } else { + (homeDirectory ?? fileManager.homeDirectoryForCurrentUser) + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } // Provider-specific by design: OpenCode stores the OpenAI OAuth entry under its own data directory. - let url = root - .appendingPathComponent("opencode", isDirectory: true) - .appendingPathComponent("auth.json") + let url = + root + .appendingPathComponent("opencode", isDirectory: true) + .appendingPathComponent("auth.json") return try self.parseOpenCode(data: self.readAuthData(at: url)) } @@ -448,7 +497,8 @@ public enum CodexOAuthCredentialsStore { return accountID } if let organizations = payload["organizations"] as? [[String: Any]], - let accountID = organizations + let accountID = + organizations .compactMap({ Self.nonEmpty($0["id"] as? String) }) .first { diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 12af9d7c4..5fc8947a7 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -591,6 +591,10 @@ public enum CodexOAuthUsageFetcher { } } + static func chatGPTUsageURL(env: [String: String]) -> URL { + self.resolveUsageURL(env: env) + } + private static func resolveUsageURL(env: [String: String]) -> URL { self.resolveUsageURL(env: env, configContents: nil) } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexCLIUserAgent.swift b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexCLIUserAgent.swift new file mode 100644 index 000000000..8065679a8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexCLIUserAgent.swift @@ -0,0 +1,54 @@ +import Foundation + +enum CodexCLIUserAgent { + static let originator = "codex_cli_rs" + + static func make(cliVersion: String?) -> String { + let platform = Self.platformName + let osVersion = Self.operatingSystemVersionString + let architecture = Self.architecture + if let version = Self.normalizedCLIVersion(cliVersion) { + return "codex_cli_rs/\(version) (\(platform) \(osVersion); \(architecture))" + } + return "codex_cli_rs (\(platform) \(osVersion); \(architecture))" + } + + static func normalizedCLIVersion(_ versionString: String?) -> String? { + guard let raw = versionString?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty + else { + return nil + } + let parts = raw.split(whereSeparator: \.isWhitespace).map(String.init) + if parts.count >= 2, parts[0].caseInsensitiveCompare("codex-cli") == .orderedSame { + let version = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + return version.isEmpty ? nil : version + } + let token = parts.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? raw + return token.isEmpty ? nil : token + } + + private static var platformName: String { + #if os(macOS) + "Mac OS" + #elseif os(Linux) + "Linux" + #else + "Darwin" + #endif + } + + private static var operatingSystemVersionString: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + private static var architecture: String { + #if arch(arm64) + "arm64" + #elseif arch(x86_64) + "x86_64" + #else + "unknown" + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATCredentials.swift new file mode 100644 index 000000000..ca8ec44b7 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATCredentials.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct CodexPATCredentials: Equatable, Sendable { + public let token: String + public let source: CodexOAuthCredentialSource + + public init(token: String, source: CodexOAuthCredentialSource = .codexHome) { + self.token = token + self.source = source + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATFetchStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATFetchStrategy.swift new file mode 100644 index 000000000..c54b018c6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATFetchStrategy.swift @@ -0,0 +1,210 @@ +import Foundation + +struct CodexPATFetchStrategy: ProviderFetchStrategy { + let id: String = "codex.pat" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + (try? CodexOAuthCredentialsStore.loadPAT(env: Self.credentialEnvironment(context.env))) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let credentialEnv = Self.credentialEnvironment(context.env) + let credentialOwner = Self.credentialOwner( + requestedEnvironment: context.env, + credentialEnvironment: credentialEnv) + let credentials = try CodexOAuthCredentialsStore.loadPAT(env: credentialEnv) + let fetched = try await CodexPATUsageFetcher.fetchUsage( + credentials: credentials, + cliVersion: Self.resolvedCLIVersion(context: context), + env: credentialEnv) + return try Self.makeResult( + usageResponse: fetched.usage, + whoami: fetched.whoami, + updatedAt: Date(), + credentialOwner: credentialOwner) + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + if let fetchError = error as? CodexOAuthFetchError { + switch fetchError { + case .unauthorized: + return true + case .invalidResponse, .serverError, .networkError: + return false + } + } + if let credentialsError = error as? CodexOAuthCredentialsError { + switch credentialsError { + case .notFound, .unreadable, .missingTokens: + return true + case .decodeFailed, .readOnlySource, .nativeRefreshRequired: + return false + } + } + return false + } + + /// PAT lives in the Codex CLI auth file. Managed-account `CODEX_HOME` isolation is for OAuth + /// workspaces and must not hide that token, including the fail-closed dummy home used when a + /// persisted managed account no longer exists. Profile homes keep a local PAT when present and + /// otherwise fall through to ambient `~/.codex`. + static func credentialEnvironment(_ env: [String: String]) -> [String: String] { + guard env["CODEX_HOME"] != nil else { return env } + if let home = env["CODEX_HOME"], self.isManagedOrFailClosedCodexHome(home) { + return self.ambientCredentialEnvironment(env) + } + if (try? CodexOAuthCredentialsStore.loadPAT(env: env)) != nil { + return env + } + return self.ambientCredentialEnvironment(env) + } + + private static func ambientCredentialEnvironment(_ env: [String: String]) -> [String: String] { + var ambient = env + // `loadPAT` only honors `CODEX_HOME`. Stripping a managed home without pointing at + // `$HOME/.codex` falls through to the process user's real home, which hides a PAT + // that tests (and some launchd/sudo environments) keep under a different HOME. + if let home = env["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), !home.isEmpty { + ambient["CODEX_HOME"] = URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".codex", isDirectory: true) + .standardizedFileURL.path + } else { + ambient.removeValue(forKey: "CODEX_HOME") + } + return ambient + } + + private static func credentialOwner( + requestedEnvironment: [String: String], + credentialEnvironment: [String: String]) -> CodexPATCredentialOwner + { + guard let requestedHome = requestedEnvironment["CODEX_HOME"]?.trimmingCharacters( + in: .whitespacesAndNewlines), + !requestedHome.isEmpty, + let credentialHome = credentialEnvironment["CODEX_HOME"]?.trimmingCharacters( + in: .whitespacesAndNewlines), + !credentialHome.isEmpty + else { return .ambientCodexHome } + let requested = URL(fileURLWithPath: requestedHome).standardizedFileURL.path + let resolved = URL(fileURLWithPath: credentialHome).standardizedFileURL.path + return requested == resolved ? .scopedCodexHome(path: requested) : .ambientCodexHome + } + + private static func isManagedOrFailClosedCodexHome(_ path: String) -> Bool { + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path + if (normalized as NSString).lastPathComponent == "managed-store-unreadable" { + return true + } + return normalized.split(separator: "/").contains("managed-codex-homes") + } + + private static func resolvedCLIVersion(context: ProviderFetchContext) -> String? { + if let version = context.resolvedCLIVersion?.trimmingCharacters(in: .whitespacesAndNewlines), + !version.isEmpty + { + return version + } + return CodexProviderDescriptor.descriptor.cli.versionDetector?(context.browserDetection) + } + + private static func makeResult( + usageResponse: CodexUsageResponse, + whoami: CodexPATWhoami?, + updatedAt: Date, + credentialOwner: CodexPATCredentialOwner = .ambientCodexHome) throws -> ProviderFetchResult + { + let credits = Self.mapCredits(response: usageResponse, updatedAt: updatedAt) + let reconciled = CodexReconciledState.fromPAT( + response: usageResponse, + whoami: whoami, + updatedAt: updatedAt) + + if let reconciled { + let dataConfidence: UsageDataConfidence = + usageResponse.rateLimit?.hasWindowDecodeFailure == true + || usageResponse.additionalRateLimitsDecodeFailed + ? .unknown + : .exact + return Self.patResult( + usage: reconciled.toUsageSnapshot().withDataConfidence(dataConfidence), + credits: credits, + credentialOwner: credentialOwner) + } + + guard credits != nil else { + throw UsageError.noRateLimitsFound + } + + return Self.patResult( + usage: UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: CodexReconciledState.patIdentity(response: usageResponse, whoami: whoami)), + credits: credits, + credentialOwner: credentialOwner) + } + + private static func mapCredits( + response: CodexUsageResponse, + updatedAt: Date) -> CreditsSnapshot? + { + let balance = response.credits?.balance + let creditLimit = response.resolvedIndividualLimit?.codexCreditLimitSnapshot(updatedAt: updatedAt) + guard balance != nil || creditLimit != nil else { return nil } + return CreditsSnapshot( + remaining: balance ?? 0, + events: [], + updatedAt: updatedAt, + codexCreditLimit: creditLimit) + } + + private static func patResult( + usage: UsageSnapshot, + credits: CreditsSnapshot?, + credentialOwner: CodexPATCredentialOwner) + -> ProviderFetchResult + { + ProviderFetchResult( + usage: usage, + credits: credits, + dashboard: nil, + sourceLabel: CodexUsageDataSource.pat.sourceLabel, + strategyID: "codex.pat", + strategyKind: .apiToken, + codexResetCreditsAttempted: true, + codexPATCredentialOwner: credentialOwner) + } +} + +#if DEBUG +extension CodexPATFetchStrategy { + static func _resolvedCLIVersionForTesting(context: ProviderFetchContext) -> String? { + self.resolvedCLIVersion(context: context) + } + + static func _credentialEnvironmentForTesting(_ env: [String: String]) -> [String: String] { + self.credentialEnvironment(env) + } + + static func _credentialOwnerForTesting(_ env: [String: String]) -> CodexPATCredentialOwner { + self.credentialOwner( + requestedEnvironment: env, + credentialEnvironment: self.credentialEnvironment(env)) + } + + static func _mapResultForTesting( + _ data: Data, + whoami: CodexPATWhoami? = nil) throws -> ProviderFetchResult + { + let usageResponse = try JSONDecoder().decode(CodexUsageResponse.self, from: data) + return try self.makeResult( + usageResponse: usageResponse, + whoami: whoami, + updatedAt: Date()) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATUsageFetcher.swift new file mode 100644 index 000000000..3cc28b3a9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexPAT/CodexPATUsageFetcher.swift @@ -0,0 +1,182 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct CodexPATWhoami: Equatable, Sendable { + public let accountId: String? + public let email: String? + public let planType: String? +} + +public struct CodexPATUsageFetch: Sendable { + public let usage: CodexUsageResponse + public let whoami: CodexPATWhoami? +} + +enum CodexPATUsageFetcher { + private static let whoamiURL = URL( + string: "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami")! + + static func fetchUsage( + credentials: CodexPATCredentials, + cliVersion: String?, + env: [String: String] = ProcessInfo.processInfo.environment) async throws -> CodexPATUsageFetch + { + try await self.fetchUsage( + credentials: credentials, + cliVersion: cliVersion, + env: env, + session: CodexAuthenticatedHTTPTransport.current) + } + + static func fetchUsage( + credentials: CodexPATCredentials, + cliVersion: String?, + env: [String: String] = ProcessInfo.processInfo.environment, + session transport: any ProviderHTTPTransport) async throws -> CodexPATUsageFetch + { + let userAgent = CodexCLIUserAgent.make(cliVersion: cliVersion) + let whoami = try await self.fetchWhoami( + token: credentials.token, + userAgent: userAgent, + session: transport) + // PAT identity comes from the token's whoami payload. A stale managed-workspace + // ChatGPT-Account-Id would query the wrong account after CODEX_HOME fail-closes. + let usage = try await self.fetchUsage( + token: credentials.token, + accountId: Self.firstNonEmpty(whoami.accountId), + userAgent: userAgent, + env: env, + session: transport) + return CodexPATUsageFetch(usage: usage, whoami: whoami) + } + + private static func fetchWhoami( + token: String, + userAgent: String, + session transport: any ProviderHTTPTransport) async throws -> CodexPATWhoami + { + var request = URLRequest( + url: Self.whoamiURL, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 30) + request.httpMethod = "GET" + Self.applyPATHeaders(to: &request, token: token, userAgent: userAgent) + + let data = try await self.perform(request: request, session: transport) + do { + return try JSONDecoder().decode(WhoamiResponse.self, from: data).model + } catch { + throw CodexOAuthFetchError.invalidResponse + } + } + + private static func fetchUsage( + token: String, + accountId: String?, + userAgent: String, + env: [String: String], + session transport: any ProviderHTTPTransport) async throws -> CodexUsageResponse + { + var request = URLRequest( + url: CodexOAuthUsageFetcher.chatGPTUsageURL(env: env), + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 30) + request.httpMethod = "GET" + Self.applyPATHeaders(to: &request, token: token, userAgent: userAgent) + if let accountId, !accountId.isEmpty { + request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") + } + + let data = try await self.perform(request: request, session: transport) + do { + return try JSONDecoder().decode(CodexUsageResponse.self, from: data) + } catch { + throw CodexOAuthFetchError.invalidResponse + } + } + + private static func applyPATHeaders( + to request: inout URLRequest, token: String, userAgent: String) + { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(CodexCLIUserAgent.originator, forHTTPHeaderField: "originator") + } + + private static func perform( + request: URLRequest, + session transport: any ProviderHTTPTransport) async throws -> Data + { + do { + let response = try await transport.response(for: request) + switch response.statusCode { + case 200...299: + return response.data + case 401, 403: + throw CodexOAuthFetchError.unauthorized + default: + let body = String(data: response.data, encoding: .utf8) + throw CodexOAuthFetchError.serverError(response.statusCode, body) + } + } catch let error as CodexOAuthFetchError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch { + if Task.isCancelled || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + throw CodexOAuthFetchError.networkError(error) + } + } + + private static func firstNonEmpty(_ candidates: String?...) -> String? { + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + return trimmed + } + } + return nil + } + + private struct WhoamiResponse: Decodable { + let chatgptAccountId: String? + let chatgptPlanType: String? + let email: String? + + private enum CodingKeys: String, CodingKey { + case chatgptAccountId = "chatgpt_account_id" + case chatgptPlanType = "chatgpt_plan_type" + case email + } + + var model: CodexPATWhoami { + CodexPATWhoami( + accountId: Self.nonEmpty(self.chatgptAccountId), + email: Self.nonEmpty(self.email), + planType: Self.nonEmpty(self.chatgptPlanType)) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + } +} + +#if DEBUG +extension CodexPATUsageFetcher { + static func _userAgentForTesting(cliVersion: String?) -> String { + CodexCLIUserAgent.make(cliVersion: cliVersion) + } + + static func _normalizedCLIVersionForTesting(_ versionString: String?) -> String? { + CodexCLIUserAgent.normalizedCLIVersion(versionString) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 51ea524af..52051bcc5 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -11,6 +11,10 @@ extension ProviderFetchContext { public enum CodexProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + /// PAT lives in Codex CLI `auth.json`, not ProviderConfig.apiKey. + private static let credentials = ProviderCredentialAdapter( + requiresAPIKeyForAPISource: false) + /// Preserve the legacy prompt behavior before probing Chromium variants that may trigger Safe Storage prompts. private static var browserCookieOrder: BrowserCookieImportOrder? { #if os(macOS) @@ -27,6 +31,7 @@ public enum CodexProviderDescriptor { menuBarMetrics: ProviderMenuBarMetricCapabilities( supported: [.automatic, .primary, .secondary, .primaryAndSecondary]), settingsSection: .init(CodexProviderSettingsKey.self), + credentials: self.credentials, metadata: ProviderMetadata( id: .codex, displayName: "Codex", @@ -113,7 +118,7 @@ public enum CodexProviderDescriptor { creditsVisibility: .requiresValueOrError, supportsInlineTokenCostDashboard: true)), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web, .cli, .oauth], + sourceModes: [.auto, .web, .cli, .oauth, .api], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "codex", @@ -126,37 +131,26 @@ public enum CodexProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + let pat = CodexPATFetchStrategy() let cli = CodexCLIUsageStrategy() let oauth = CodexOAuthFetchStrategy() let web = CodexWebDashboardStrategy() - - switch context.runtime { + let oauthWithNativeRefresh: [any ProviderFetchStrategy] = [oauth, CodexOAuthNativeRefreshCLIStrategy()] + let autoStrategies: [any ProviderFetchStrategy] = context.codexWorkspaceID == nil + ? [pat, oauth, cli] + : [pat, oauth] + + switch context.sourceMode { + case .oauth: + return oauthWithNativeRefresh + case .web: + return [web] case .cli: - switch context.sourceMode { - case .oauth: - return [oauth, CodexOAuthNativeRefreshCLIStrategy()] - case .web: - return [web] - case .cli: - return [cli] - case .api: - return [] - case .auto: - return context.codexWorkspaceID == nil ? [oauth, cli] : [oauth] - } - case .app: - switch context.sourceMode { - case .oauth: - return [oauth, CodexOAuthNativeRefreshCLIStrategy()] - case .cli: - return [cli] - case .web: - return [web] - case .api: - return [] - case .auto: - return context.codexWorkspaceID == nil ? [oauth, cli] : [oauth] - } + return [cli] + case .api: + return [pat] + case .auto: + return autoStrategies } } @@ -249,9 +243,13 @@ public enum CodexProviderDescriptor { public static func resolveUsageStrategy( selectedDataSource: CodexUsageDataSource, - hasOAuthCredentials: Bool) -> CodexUsageStrategy + hasOAuthCredentials: Bool, + hasPATCredentials: Bool = false) -> CodexUsageStrategy { if selectedDataSource == .auto { + if hasPATCredentials { + return CodexUsageStrategy(dataSource: .pat) + } if hasOAuthCredentials { return CodexUsageStrategy(dataSource: .oauth) } @@ -568,6 +566,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { strategyID: result.strategyID, strategyKind: result.strategyKind, codexResetCreditsAttempted: true, + codexPATCredentialOwner: result.codexPATCredentialOwner, diagnostic: result.diagnostic, claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, @@ -613,6 +612,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { strategyID: oauthResult.strategyID, strategyKind: oauthResult.strategyKind, codexResetCreditsAttempted: oauthResult.codexResetCreditsAttempted, + codexPATCredentialOwner: oauthResult.codexPATCredentialOwner, diagnostic: oauthResult.diagnostic) } @@ -693,6 +693,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { strategyID: result.strategyID, strategyKind: result.strategyKind, codexResetCreditsAttempted: result.codexResetCreditsAttempted, + codexPATCredentialOwner: result.codexPATCredentialOwner, diagnostic: result.diagnostic, claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift b/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift index adcbca1a6..a4ede0f5e 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift @@ -52,6 +52,21 @@ public struct CodexReconciledState: Sendable { updatedAt: updatedAt) } + public static func fromPAT( + response: CodexUsageResponse, + whoami: CodexPATWhoami?, + updatedAt: Date = Date()) -> CodexReconciledState? + { + self.make( + primary: self.makeWindow(response.rateLimit?.primaryWindow), + secondary: self.makeWindow(response.rateLimit?.secondaryWindow), + extraRateWindows: CodexAdditionalRateLimitMapper.extraRateWindows( + from: response.additionalRateLimits, + now: updatedAt), + identity: self.patIdentity(response: response, whoami: whoami), + updatedAt: updatedAt) + } + public static func fromAttachedDashboard( snapshot: OpenAIDashboardSnapshot, provider: UsageProvider = .codex, @@ -99,6 +114,17 @@ public struct CodexReconciledState: Sendable { loginMethod: self.resolvePlan(response: response, credentials: credentials)) } + public static func patIdentity( + response: CodexUsageResponse, + whoami: CodexPATWhoami?) -> ProviderIdentitySnapshot + { + ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: whoami?.email, + accountOrganization: nil, + loginMethod: self.resolvePATPlan(response: response, whoami: whoami)) + } + private static func make( primary: RateWindow?, secondary: RateWindow?, @@ -148,6 +174,11 @@ public struct CodexReconciledState: Sendable { return email?.trimmingCharacters(in: .whitespacesAndNewlines) } + private static func resolvePATPlan(response: CodexUsageResponse, whoami: CodexPATWhoami?) -> String? { + if let plan = response.planType?.rawValue, !plan.isEmpty { return plan } + return whoami?.planType + } + private static func resolvePlan(response: CodexUsageResponse, credentials: CodexOAuthCredentials) -> String? { if let plan = response.planType?.rawValue, !plan.isEmpty { return plan } guard let idToken = credentials.idToken, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift index b3bb16cd0..0f5c02c8a 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift @@ -2,6 +2,7 @@ import Foundation public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { case auto + case pat case oauth case cli @@ -12,6 +13,7 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { public var displayName: String { switch self { case .auto: "Auto" + case .pat: "PAT" case .oauth: "OAuth API" case .cli: "CLI (RPC/PTY)" } @@ -21,6 +23,8 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable { switch self { case .auto: "auto" + case .pat: + "pat" case .oauth: "oauth" case .cli: diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift index 6e959441b..0729f5c36 100644 --- a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift +++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift @@ -1,8 +1,8 @@ import Foundation extension ProviderConfig { - /// Account slug (the segment after `/accounts/` in console URLs) that owns `apiKey`. - /// Fireworks does not expose a whoami endpoint, so the slug cannot be derived from the key. + /// Account slug that owns `apiKey`. When omitted, QuotaKit discovers it from the + /// accounts visible to the Fireworks API key. public var accountSlug: String? { get { self.extensionValue(forKey: "accountSlug") } set { self.setExtensionValue(newValue, forKey: "accountSlug") } diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift index b82796c0d..d60c80ea0 100644 --- a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift @@ -9,24 +9,7 @@ public enum FireworksProviderDescriptor { key: FireworksSettingsReader.configAccountSlugEnvironmentKey, value: { $0.sanitizedAccountSlug }), ], - resolve: FireworksSettingsReader.apiKey, - configValidator: { config in - guard config.sanitizedAPIKey != nil, config.sanitizedAccountSlug == nil else { - return [] - } - return [CodexBarConfigIssue( - severity: .error, - provider: .fireworks, - field: "accountSlug", - code: "missing_account_slug", - message: "Fireworks needs the account slug from app.fireworks.ai/accounts/ to read billing.")] - }, - missingCredentialMessage: { environment in - guard FireworksSettingsReader.apiKey(environment: environment) != nil else { - return nil - } - return "Fireworks needs the account slug (set FIREWORKS_ACCOUNT_SLUG or the slug field in Settings)." - }) + resolve: FireworksSettingsReader.apiKey) static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( @@ -84,21 +67,23 @@ struct FireworksAPIFetchStrategy: ProviderFetchStrategy { func isAvailable(_ context: ProviderFetchContext) async -> Bool { FireworksSettingsReader.apiKey(environment: context.env) != nil - && FireworksSettingsReader.accountSlug(environment: context.env) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { guard let apiKey = FireworksSettingsReader.apiKey(environment: context.env) else { throw FireworksUsageError.missingCredentials } - guard let accountSlug = FireworksSettingsReader.accountSlug(environment: context.env) else { - throw FireworksUsageError.missingAccountSlug - } let usage = try await FireworksUsageFetcher.fetchUsage( apiKey: apiKey, - accountSlug: accountSlug, + accountSlug: FireworksSettingsReader.accountSlug(environment: context.env), session: self.transport) - return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + let sourceLabel = usage.accountSlugWasDiscovered + ? "api · \(usage.accountSlug) (auto-discovered)" + : "api · \(usage.accountSlug)" + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: sourceLabel, + fireworksDiscoveredAccountSlug: usage.accountSlugWasDiscovered ? usage.accountSlug : nil) } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift index c6fe299bb..d8d5745af 100644 --- a/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift @@ -6,9 +6,17 @@ import FoundationNetworking public struct FireworksUsageSnapshot: Sendable { public let summary: FireworksUsageSummary + public let accountSlug: String + public let accountSlugWasDiscovered: Bool - public init(summary: FireworksUsageSummary) { + public init( + summary: FireworksUsageSummary, + accountSlug: String = "", + accountSlugWasDiscovered: Bool = false) + { self.summary = summary + self.accountSlug = accountSlug + self.accountSlugWasDiscovered = accountSlugWasDiscovered } public func toUsageSnapshot() -> UsageSnapshot { @@ -57,8 +65,10 @@ public struct FireworksUsageSummary: Sendable { public enum FireworksUsageError: LocalizedError, Sendable, Equatable { case missingCredentials - case missingAccountSlug case invalidAccountSlug(String) + case accountNotFound(String) + case noAccountsFound + case multipleAccountsFound([String]) case authenticationRejected case rateLimited case apiError(Int) @@ -68,10 +78,18 @@ public enum FireworksUsageError: LocalizedError, Sendable, Equatable { switch self { case .missingCredentials: "Missing Fireworks API key. Add one in Settings or set FIREWORKS_API_KEY." - case .missingAccountSlug: - "Missing Fireworks account slug. Set FIREWORKS_ACCOUNT_SLUG or the slug field in Settings." case let .invalidAccountSlug(slug): "Invalid Fireworks account slug '\(slug)'. Please double-check the account slug in Settings." + case let .accountNotFound(slug): + "Fireworks account slug '\(slug)' not found for this API key. Leave the slug blank to auto-discover " + + "it, choose it in the app.fireworks.ai account switcher, or run 'firectl whoami'." + case .noAccountsFound: + "No Fireworks accounts are visible to this API key. Check the key in app.fireworks.ai or run " + + "'firectl whoami'." + case let .multipleAccountsFound(slugs): + "This Fireworks API key can access multiple accounts: \(slugs.joined(separator: ", ")). Set the " + + "account slug in Settings or FIREWORKS_ACCOUNT_SLUG; find it in the app.fireworks.ai account " + + "switcher or with 'firectl whoami'." case .authenticationRejected: "Fireworks rejected the API key. Create a new key at app.fireworks.ai and update Settings." case .rateLimited: @@ -93,7 +111,7 @@ public struct FireworksUsageFetcher: Sendable { public static func fetchUsage( apiKey: String, - accountSlug: String, + accountSlug: String?, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date()) async throws -> FireworksUsageSnapshot { @@ -101,25 +119,78 @@ public struct FireworksUsageFetcher: Sendable { guard !cleanedKey.isEmpty else { throw FireworksUsageError.missingCredentials } - let cleanedSlug = accountSlug.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleanedSlug.isEmpty else { - throw FireworksUsageError.missingAccountSlug + let cleanedSlug = accountSlug?.trimmingCharacters(in: .whitespacesAndNewlines) + if let cleanedSlug, !cleanedSlug.isEmpty { + return try await self.fetchConfiguredAccount( + apiKey: cleanedKey, + accountSlug: cleanedSlug, + transport: transport, + now: now) } - let startTime = now.addingTimeInterval(-TimeInterval(self.lookbackDays * 24 * 60 * 60)) - var request = try URLRequest( - url: Self.resolveSummaryURL(accountSlug: cleanedSlug, startTime: startTime, endTime: now)) - request.httpMethod = "GET" - request.setValue("Bearer \(cleanedKey)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.timeoutInterval = Self.timeoutSeconds + let slugs = try await self.listAccountSlugs(apiKey: cleanedKey, transport: transport) + let discoveredSlug = try self.singleDiscoveredAccount(from: slugs) + let summary = try await self.fetchSummary( + apiKey: cleanedKey, + accountSlug: discoveredSlug, + transport: transport, + now: now) + return FireworksUsageSnapshot( + summary: summary, + accountSlug: discoveredSlug, + accountSlugWasDiscovered: true) + } - let response: ProviderHTTPResponse + private static func fetchConfiguredAccount( + apiKey: String, + accountSlug: String, + transport: any ProviderHTTPTransport, + now: Date) async throws -> FireworksUsageSnapshot + { do { - response = try await transport.response(for: request) - } catch { - throw error + let summary = try await self.fetchSummary( + apiKey: apiKey, + accountSlug: accountSlug, + transport: transport, + now: now) + if summary.last30DaysSpend == nil { + let slugs = try await self.listAccountSlugs(apiKey: apiKey, transport: transport) + guard slugs.contains(accountSlug) else { + throw FireworksUsageError.accountNotFound(accountSlug) + } + } + return FireworksUsageSnapshot(summary: summary, accountSlug: accountSlug) + } catch FireworksUsageError.apiError(404) { + let slugs = try await self.listAccountSlugs(apiKey: apiKey, transport: transport) + guard slugs.count == 1, let discoveredSlug = slugs.first else { + if slugs.isEmpty { + throw FireworksUsageError.accountNotFound(accountSlug) + } + throw FireworksUsageError.multipleAccountsFound(slugs) + } + let summary = try await self.fetchSummary( + apiKey: apiKey, + accountSlug: discoveredSlug, + transport: transport, + now: now) + return FireworksUsageSnapshot( + summary: summary, + accountSlug: discoveredSlug, + accountSlugWasDiscovered: discoveredSlug != accountSlug) } + } + + private static func fetchSummary( + apiKey: String, + accountSlug: String, + transport: any ProviderHTTPTransport, + now: Date) async throws -> FireworksUsageSummary + { + let startTime = now.addingTimeInterval(-TimeInterval(self.lookbackDays * 24 * 60 * 60)) + var request = try URLRequest( + url: Self.resolveSummaryURL(accountSlug: accountSlug, startTime: startTime, endTime: now)) + self.authorize(&request, apiKey: apiKey) + let response = try await transport.response(for: request) switch response.statusCode { case 200: @@ -133,8 +204,65 @@ public struct FireworksUsageFetcher: Sendable { throw FireworksUsageError.apiError(response.statusCode) } - let summary = try self.parseSummary(data: response.data, now: now) - return FireworksUsageSnapshot(summary: summary) + return try self.parseSummary(data: response.data, now: now) + } + + private static func listAccountSlugs( + apiKey: String, + transport: any ProviderHTTPTransport) async throws -> [String] + { + var slugs: Set = [] + var pageToken: String? + repeat { + var request = URLRequest(url: self.resolveAccountsURL(pageToken: pageToken)) + self.authorize(&request, apiKey: apiKey) + let response = try await transport.response(for: request) + switch response.statusCode { + case 200: + break + case 401, 403: + throw FireworksUsageError.authenticationRejected + case 429: + throw FireworksUsageError.rateLimited + default: + Self.log.error("Fireworks accounts API returned HTTP \(response.statusCode)") + throw FireworksUsageError.apiError(response.statusCode) + } + + let page: FireworksAccountsResponse + do { + page = try JSONDecoder().decode(FireworksAccountsResponse.self, from: response.data) + } catch { + throw FireworksUsageError.parseFailed(error.localizedDescription) + } + for account in page.accounts ?? [] { + if let slug = account.slug, self.isValidAccountSlug(slug) { + slugs.insert(slug) + } + } + pageToken = page.nextPageToken?.trimmingCharacters(in: .whitespacesAndNewlines) + if pageToken?.isEmpty == true { + pageToken = nil + } + } while pageToken != nil + return slugs.sorted() + } + + private static func singleDiscoveredAccount(from slugs: [String]) throws -> String { + guard !slugs.isEmpty else { + throw FireworksUsageError.noAccountsFound + } + guard slugs.count == 1, let slug = slugs.first else { + throw FireworksUsageError.multipleAccountsFound(slugs) + } + return slug + } + + private static func authorize(_ request: inout URLRequest, apiKey: String) { + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = self.timeoutSeconds } /// Characters permitted in a Fireworks account slug. Fireworks slugs are simple @@ -145,6 +273,18 @@ public struct FireworksUsageFetcher: Sendable { private static let accountSlugAllowedCharacters = CharacterSet( charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") + private static func isValidAccountSlug(_ slug: String) -> Bool { + !slug.isEmpty && slug.rangeOfCharacter(from: self.accountSlugAllowedCharacters.inverted) == nil + } + + public static func resolveAccountsURL(pageToken: String? = nil) -> URL { + var components = URLComponents(string: "https://api.fireworks.ai/v1/accounts")! + if let pageToken { + components.queryItems = [URLQueryItem(name: "pageToken", value: pageToken)] + } + return components.url! + } + /// `https://api.fireworks.ai/v1/accounts//billing/summary` with an explicit /// 30-day `startTime`/`endTime` window. /// - Throws: `FireworksUsageError.invalidAccountSlug` if the slug cannot be embedded @@ -228,6 +368,27 @@ private struct FireworksBillingSummaryResponse: Decodable { let usageBuckets: [FireworksUsageBucket]? } +private struct FireworksAccountsResponse: Decodable { + let accounts: [FireworksAccount]? + let nextPageToken: String? +} + +private struct FireworksAccount: Decodable { + let name: String? + let accountId: String? + let id: String? + + var slug: String? { + for value in [self.accountId, self.id, self.name] { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + continue + } + return value.split(separator: "/").last.map(String.init) + } + return nil + } +} + private struct FireworksLineItem: Decodable { let category: String? let groupingKey: String? diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift index c5b1afded..7270b4029 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -1,5 +1,20 @@ import Foundation +/// One local-calendar day of Grok session-token activity. +public struct GrokLocalDailyBucket: Sendable, Equatable { + public let date: String + public let totalTokens: Int + public let sessionCount: Int + public let models: [String] + + public init(date: String, totalTokens: Int, sessionCount: Int, models: [String]) { + self.date = date + self.totalTokens = totalTokens + self.sessionCount = sessionCount + self.models = models + } +} + /// Aggregated stats from local `~/.grok/sessions/**/signals.json` files. /// Used as a local fallback view when the JSON-RPC billing call is unavailable. public struct GrokLocalSessionSummary: Sendable { @@ -8,19 +23,53 @@ public struct GrokLocalSessionSummary: Sendable { public let lastSessionAt: Date? public let primaryModel: String? public let models: [String] + public let daily: [GrokLocalDailyBucket] + public let scannedAt: Date public init( sessionCount: Int, totalTokens: Int, lastSessionAt: Date?, primaryModel: String?, - models: [String]) + models: [String], + daily: [GrokLocalDailyBucket] = [], + scannedAt: Date = .init()) { self.sessionCount = sessionCount self.totalTokens = totalTokens self.lastSessionAt = lastSessionAt self.primaryModel = primaryModel self.models = models + self.daily = daily + self.scannedAt = scannedAt + } + + /// Local session tokens only. SuperGrok credits are a quota, not dollars, so this never invents spend. + public func toCostUsageTokenSnapshot(historyDays: Int) -> CostUsageTokenSnapshot? { + let entries = self.daily.map { bucket in + CostUsageDailyReport.Entry( + date: bucket.date, + inputTokens: nil, + outputTokens: nil, + totalTokens: bucket.totalTokens, + requestCount: bucket.sessionCount, + costUSD: nil, + modelsUsed: bucket.models.isEmpty ? nil : bucket.models, + modelBreakdowns: nil) + } + guard !entries.isEmpty else { return nil } + let todayKey = GrokLocalSessionScanner.dayKey(for: self.scannedAt, calendar: .current) + let todayTokens = todayKey.flatMap { key in self.daily.first { $0.date == key }?.totalTokens } + return CostUsageTokenSnapshot( + sessionTokens: todayTokens, + sessionCostUSD: nil, + last30DaysTokens: self.totalTokens, + last30DaysCostUSD: nil, + historyDays: historyDays, + historyCoverageIsEstablished: true, + costProvenance: .unknown, + daily: entries, + updatedAt: self.scannedAt) } } @@ -46,14 +95,19 @@ public enum GrokLocalSessionScanner { totalTokens: 0, lastSessionAt: nil, primaryModel: nil, - models: []) + models: [], + scannedAt: now) } - let lookbackCutoff = Calendar.current.date(byAdding: .day, value: -lookbackDays, to: now) ?? now + let calendar = Calendar.current + let lookbackCutoff = calendar.date(byAdding: .day, value: -lookbackDays, to: now) ?? now var sessionCount = 0 var totalTokens = 0 var lastSessionAt: Date? var modelCounts: [String: Int] = [:] + var dailyTokens: [String: Int] = [:] + var dailySessions: [String: Int] = [:] + var dailyModels: [String: [String: Int]] = [:] while let url = rootEnum.nextObject() as? URL { guard url.lastPathComponent == "signals.json" else { continue } @@ -68,33 +122,63 @@ public enum GrokLocalSessionScanner { sessionCount += 1 let beforeCompaction = (json["totalTokensBeforeCompaction"] as? Int) ?? 0 let contextUsed = (json["contextTokensUsed"] as? Int) ?? 0 - totalTokens += beforeCompaction + contextUsed + let sessionTokens = beforeCompaction + contextUsed + totalTokens += sessionTokens if mtime > (lastSessionAt ?? Date.distantPast) { lastSessionAt = mtime } + var sessionModels: [String] = [] if let primary = (json["primaryModelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !primary.isEmpty { modelCounts[primary, default: 0] += 1 + sessionModels.append(primary) } if let models = json["modelsUsed"] as? [String] { for model in models { let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { modelCounts[trimmed, default: 0] += 1 + sessionModels.append(trimmed) } } } + + if let day = Self.dayKey(for: mtime, calendar: calendar) { + dailyTokens[day, default: 0] += sessionTokens + dailySessions[day, default: 0] += 1 + for model in sessionModels { + dailyModels[day, default: [:]][model, default: 0] += 1 + } + } } let sortedModels = modelCounts.sorted { $0.value > $1.value }.map(\.key) + let daily = dailyTokens.keys.sorted().map { day in + let models = (dailyModels[day] ?? [:]).sorted { $0.value > $1.value }.map(\.key) + return GrokLocalDailyBucket( + date: day, + totalTokens: dailyTokens[day] ?? 0, + sessionCount: dailySessions[day] ?? 0, + models: models) + } return GrokLocalSessionSummary( sessionCount: sessionCount, totalTokens: totalTokens, lastSessionAt: lastSessionAt, primaryModel: sortedModels.first, - models: sortedModels) + models: sortedModels, + daily: daily, + scannedAt: now) + } + + static func dayKey(for date: Date, calendar: Calendar) -> String? { + let components = calendar.dateComponents([.year, .month, .day], from: date) + guard let year = components.year, let month = components.month, let day = components.day else { + return nil + } + return String(format: "%04d-%02d-%02d", year, month, day) } } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index b99f20721..7585aa073 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -85,8 +85,11 @@ public enum GrokProviderDescriptor { ProviderColor(hex: 0xFDFDFD), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Grok cost summary is not supported yet." }), + supportsTokenCost: true, + noDataMessage: { + "Grok token totals come from local ~/.grok/sessions logs. " + + "Subscription credits are not converted to dollars." + }), // Pace needs a real period length, not a guess. Both fetch paths now supply one — the // CLI from `billingPeriodMinutes`, the web fallback from `GrokBillingCadenceStore` — so // this no longer keys off the inferred "Weekly"/"Monthly" label. That label goes `nil` diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift index 20bf173e8..247886661 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift @@ -9,7 +9,7 @@ final class GrokRPCClient: @unchecked Sendable { private static let log = CodexBarLog.logger(LogCategories.provider(.grok)) private let process = Process() - private let stdinPipe = Pipe() + private let stdin = RPCChildProcessInput() private let stdoutPipe = Pipe() private let stderrPipe = Pipe() private let initializeTimeoutSeconds: TimeInterval @@ -47,7 +47,7 @@ final class GrokRPCClient: @unchecked Sendable { self.process.environment = env self.process.executableURL = URL(fileURLWithPath: "/usr/bin/env") self.process.arguments = [resolvedExec] + arguments - self.process.standardInput = self.stdinPipe + self.process.standardInput = self.stdin.pipe self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe @@ -63,7 +63,7 @@ final class GrokRPCClient: @unchecked Sendable { let stdoutLineContinuation = self.stdoutLineContinuation let stdoutBuffer = BoundedLineBuffer() let process = self.process - let stdinPipe = self.stdinPipe + let stdin = self.stdin stdoutHandle.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { @@ -76,7 +76,7 @@ final class GrokRPCClient: @unchecked Sendable { Self.log.warning("Grok RPC line exceeded memory limit; terminating process") handle.readabilityHandler = nil DispatchQueue.global(qos: .userInitiated).async { - RPCChildProcessTeardown.terminate(process: process, stdinPipe: stdinPipe) + RPCChildProcessTeardown.terminate(process: process, stdin: stdin) } stdoutLineContinuation.finish() return @@ -128,7 +128,7 @@ final class GrokRPCClient: @unchecked Sendable { func shutdown() { Self.log.debug("Grok RPC stopping") - RPCChildProcessTeardown.terminate(process: self.process, stdinPipe: self.stdinPipe) + RPCChildProcessTeardown.terminate(process: self.process, stdin: self.stdin) } // MARK: - JSON-RPC plumbing (mirrors CodexRPCClient) @@ -195,9 +195,9 @@ final class GrokRPCClient: @unchecked Sendable { // Dispatch off the timeout task so the bounded TERM-to-KILL wait cannot delay the timeout // error or let the stdout-EOF failure win the race; `shutdown()` remains the synchronous backstop. let process = self.process - let stdinPipe = self.stdinPipe + let stdin = self.stdin DispatchQueue.global(qos: .userInitiated).async { - RPCChildProcessTeardown.terminate(process: process, stdinPipe: stdinPipe) + RPCChildProcessTeardown.terminate(process: process, stdin: stdin) } } @@ -221,12 +221,16 @@ final class GrokRPCClient: @unchecked Sendable { // the on-the-wire shape the grok agent expects. let unescaped = String(data: raw, encoding: .utf8)? .replacingOccurrences(of: "\\/", with: "/") - let data = unescaped.flatMap { $0.data(using: .utf8) } ?? raw + var data = unescaped.flatMap { $0.data(using: .utf8) } ?? raw if let preview = String(data: data.prefix(200), encoding: .utf8) { Self.log.debug("grok rpc -> \(preview)") } - self.stdinPipe.fileHandleForWriting.write(data) - self.stdinPipe.fileHandleForWriting.write(Data([0x0A])) + data.append(0x0A) + do { + try self.stdin.write(data) + } catch { + throw GrokRPCError.requestFailed("grok agent stdin closed: \(error.localizedDescription)") + } } private func readNextMessage() async throws -> [String: Any] { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 1b2f4912b..ea59bb750 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -71,6 +71,8 @@ public struct GrokUsageSnapshot: Sendable { primary: primary, secondary: nil, tertiary: nil, + costUsage: self.localSummary?.toCostUsageTokenSnapshot( + historyDays: GrokLocalSessionScanner.defaultLookbackDays), grokUsage: self, updatedAt: self.updatedAt, identity: identity) diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 5436b57a4..505cdedf0 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif #if canImport(Darwin) import Darwin #elseif canImport(Glibc) @@ -23,6 +26,8 @@ public struct KiroUsageSnapshot: Sendable { public let estimatedOverageCostUSD: Double? public let manageURL: String? public let contextUsage: KiroContextUsageSnapshot? + /// Plan and overage ceilings from `GetUsageLimits`, which the CLI report cannot express. + public let usageLimits: KiroUsageLimits? public let resetsAt: Date? public let updatedAt: Date @@ -42,6 +47,7 @@ public struct KiroUsageSnapshot: Sendable { estimatedOverageCostUSD: Double? = nil, manageURL: String? = nil, contextUsage: KiroContextUsageSnapshot? = nil, + usageLimits: KiroUsageLimits? = nil, resetsAt: Date?, updatedAt: Date) { @@ -60,10 +66,40 @@ public struct KiroUsageSnapshot: Sendable { self.estimatedOverageCostUSD = estimatedOverageCostUSD self.manageURL = manageURL self.contextUsage = contextUsage + self.usageLimits = usageLimits self.resetsAt = resetsAt self.updatedAt = updatedAt } + /// Returns a copy carrying the API's plan/overage ceilings. + func withUsageLimits(_ usageLimits: KiroUsageLimits?) -> Self { + guard let usageLimits else { return self } + return Self( + planName: self.planName, + displayPlanName: self.displayPlanName, + accountEmail: self.accountEmail, + authMethod: self.authMethod, + creditsUsed: usageLimits.hasUnseparatedBonus ? self.creditsUsed : usageLimits.planUsed, + creditsTotal: usageLimits.hasUnseparatedBonus ? self.creditsTotal : usageLimits.planLimit, + creditsPercent: usageLimits.hasUnseparatedBonus || usageLimits.planLimit <= 0 + ? self.creditsPercent + : (usageLimits.planUsed / usageLimits.planLimit) * 100.0, + bonusCreditsUsed: self.bonusCreditsUsed, + bonusCreditsTotal: self.bonusCreditsTotal, + bonusExpiryDays: self.bonusExpiryDays, + overagesStatus: usageLimits.overageEnabled == false + ? "Disabled" + : (usageLimits.overageEnabled == true ? self.overagesStatus ?? "Enabled" : self.overagesStatus), + overageCreditsUsed: usageLimits.overageUsed, + estimatedOverageCostUSD: usageLimits.overageCharges + ?? (usageLimits.currencyCode.uppercased() == "USD" ? self.estimatedOverageCostUSD : nil), + manageURL: self.manageURL, + contextUsage: self.contextUsage, + usageLimits: usageLimits, + resetsAt: usageLimits.resetsAt, + updatedAt: self.updatedAt) + } + public func toUsageSnapshot() -> UsageSnapshot { let primary = RateWindow( usedPercent: self.creditsPercent, @@ -112,19 +148,42 @@ public struct KiroUsageSnapshot: Sendable { if let overagesStatus = self.overagesStatus { detailRows.append(.makeRow(label: "Overages", value: overagesStatus)) } - let overagesEnabled = self.overagesStatus? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .hasPrefix("enabled") == true + // The API states whether overage is enabled. The CLI omits the whole overage section for + // organization accounts, so its status line is only consulted when the API is unavailable. + let overageCap = self.usageLimits?.overageCap + let overagesEnabled: Bool = if let limits = self.usageLimits { + if let enabled = limits.overageEnabled { + enabled && overageCap != nil + } else { + self.overagesStatus? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("enabled") == true + } + } else { + self.overagesStatus? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("enabled") == true + } if overagesEnabled, let overageCreditsUsed = self.overageCreditsUsed { detailRows.append(.makeRow( label: "Overage usage", - value: "\(UsageFormatter.kiroCreditNumber(overageCreditsUsed)) credits")) + value: "\(UsageFormatter.kiroCreditNumber(overageCreditsUsed)) credits", + secondaryValue: overageCap.map { "of \(UsageFormatter.kiroCreditNumber($0))" })) + } + if overagesEnabled, let overageCap, let overageCreditsUsed = self.overageCreditsUsed { + detailRows.append(.makeRow( + label: "Overage credits left", + value: UsageFormatter.kiroCreditNumber(max(0, overageCap - overageCreditsUsed)))) } if overagesEnabled, let estimatedOverageCostUSD = self.estimatedOverageCostUSD { + let currencyCode = self.usageLimits?.currencyCode ?? "USD" detailRows.append(.makeRow( label: "Overage cost", - value: UsageFormatter.usdString(estimatedOverageCostUSD))) + value: UsageFormatter.currencyString(estimatedOverageCostUSD, currencyCode: currencyCode), + secondaryValue: self.usageLimits?.overageChargeLimit + .map { "of \(UsageFormatter.currencyString($0, currencyCode: currencyCode))" })) } if let contextUsage = self.contextUsage { detailRows.append(.makeRow( @@ -144,10 +203,37 @@ public struct KiroUsageSnapshot: Sendable { detailRows.append(.makeRow(label: "Manage", value: manageURL)) } + // Overage is spendable headroom above the plan with its own ceiling, so it is a window of + // its own rather than part of the plan gauge — `creditsUsed` already excludes it. + var extraRateWindows: [NamedRateWindow] = [] + if let limits = self.usageLimits, let overageCap = limits.overageCap, overageCap > 0 { + extraRateWindows.append(NamedRateWindow( + id: "kiro-overage", + title: "Overage", + window: RateWindow( + usedPercent: min(100, (limits.overageUsed / overageCap) * 100.0), + windowMinutes: nil, + resetsAt: limits.resetsAt, + resetDescription: nil))) + } + + let providerCost: ProviderCostSnapshot? = self.usageLimits.flatMap { limits in + guard let charges = limits.overageCharges, let chargeLimit = limits.overageChargeLimit + else { return nil } + return ProviderCostSnapshot( + used: charges, + limit: chargeLimit, + currencyCode: limits.currencyCode, + period: "Overage", + resetsAt: limits.resetsAt, + updatedAt: self.updatedAt) + } + return UsageSnapshot( primary: primary, secondary: secondary, tertiary: nil, + extraRateWindows: extraRateWindows.isEmpty ? nil : extraRateWindows, kiroUsage: KiroUsageDetails( planName: self.planName, displayPlanName: self.displayPlanName, @@ -162,8 +248,10 @@ public struct KiroUsageSnapshot: Sendable { overageCreditsUsed: self.overageCreditsUsed, estimatedOverageCostUSD: self.estimatedOverageCostUSD, manageURL: self.manageURL, - contextUsage: self.contextUsage), - providerCost: nil, + contextUsage: self.contextUsage, + usageLimits: self.usageLimits, + resetsAt: self.resetsAt), + providerCost: providerCost, details: [.makeSection(title: "Usage", rows: detailRows)], updatedAt: self.updatedAt, identity: identity) @@ -217,6 +305,8 @@ public struct KiroUsageDetails: Codable, Equatable, Sendable { public let estimatedOverageCostUSD: Double? public let manageURL: String? public let contextUsage: KiroContextUsageSnapshot? + public let usageLimits: KiroUsageLimits? + public let resetsAt: Date? public init( planName: String, @@ -232,7 +322,9 @@ public struct KiroUsageDetails: Codable, Equatable, Sendable { overageCreditsUsed: Double?, estimatedOverageCostUSD: Double?, manageURL: String?, - contextUsage: KiroContextUsageSnapshot?) + contextUsage: KiroContextUsageSnapshot?, + usageLimits: KiroUsageLimits? = nil, + resetsAt: Date? = nil) { self.planName = planName self.displayPlanName = displayPlanName @@ -248,6 +340,8 @@ public struct KiroUsageDetails: Codable, Equatable, Sendable { self.estimatedOverageCostUSD = estimatedOverageCostUSD self.manageURL = manageURL self.contextUsage = contextUsage + self.usageLimits = usageLimits + self.resetsAt = resetsAt } } @@ -302,6 +396,7 @@ public struct KiroStatusProbe: Sendable { private let contextProbeTimeout: TimeInterval private let pipeTimeoutCap: TimeInterval private let pipeProcessRegistry: PipeProcessRegistry + private let usageLimitsFetcher: @Sendable () async throws -> KiroUsageLimits public init() { self.cliBinaryResolver = { TTYCommandRunner.which("kiro-cli") } @@ -310,6 +405,7 @@ public struct KiroStatusProbe: Sendable { self.contextProbeTimeout = 8.0 self.pipeTimeoutCap = 5.0 self.pipeProcessRegistry = .live + self.usageLimitsFetcher = { try await KiroUsageLimitsAPI.fetch() } } init( @@ -318,7 +414,10 @@ public struct KiroStatusProbe: Sendable { usageProbeTimeout: TimeInterval = 20.0, contextProbeTimeout: TimeInterval = 8.0, pipeTimeoutCap: TimeInterval = 5.0, - pipeProcessRegistry: PipeProcessRegistry = .live) + pipeProcessRegistry: PipeProcessRegistry = .live, + usageLimitsFetcher: @escaping @Sendable () async throws -> KiroUsageLimits = { + throw KiroUsageLimitsError.credentialsUnavailable("not configured in tests") + }) { self.cliBinaryResolver = cliBinaryResolver self.accountProbeTimeout = accountProbeTimeout @@ -326,6 +425,7 @@ public struct KiroStatusProbe: Sendable { self.contextProbeTimeout = contextProbeTimeout self.pipeTimeoutCap = pipeTimeoutCap self.pipeProcessRegistry = pipeProcessRegistry + self.usageLimitsFetcher = usageLimitsFetcher } private static let logger = CodexBarLog.logger(LogCategories.provider(.kiro)) @@ -377,8 +477,9 @@ public struct KiroStatusProbe: Sendable { let accountStatus = try await self.awaitAccountStatus(accountTask) let accountInfo = accountStatus.account + let snapshot: KiroUsageSnapshot do { - return try self.parse( + snapshot = try self.parse( output: output, accountEmail: accountInfo?.email, authMethod: accountInfo?.authMethod, @@ -386,6 +487,28 @@ public struct KiroStatusProbe: Sendable { } catch KiroStatusProbeError.parseError where accountStatus == .notLoggedIn { throw KiroStatusProbeError.notLoggedIn } + let limits = try await self.fetchUsageLimits() + return snapshot.withUsageLimits(limits) + } + + /// Enriches the CLI report with the plan/overage ceilings only the API states. + /// + /// Best-effort: the API path depends on the CLI's private token store, which a Kiro release can + /// move, whereas the CLI report reads nothing but its own published output. A failure leaves the + /// plan-relative numbers the CLI already produced. The CLI runs first here, so any token it + /// refreshed along the way is already in place by the time this reads it. + private func fetchUsageLimits() async throws -> KiroUsageLimits? { + do { + return try await self.usageLimitsFetcher() + } catch is CancellationError { + throw CancellationError() + } catch { + if Task.isCancelled || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + Self.logger.debug("Kiro usage API unavailable: \(error.localizedDescription)") + return nil + } } struct KiroAccountInfo: Equatable { diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroUsageLimitsAPI.swift b/Sources/CodexBarCore/Providers/Kiro/KiroUsageLimitsAPI.swift new file mode 100644 index 000000000..1085535e8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kiro/KiroUsageLimitsAPI.swift @@ -0,0 +1,378 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +/// Kiro credit usage read from the service the official CLI itself calls. +/// +/// The CLI's `/usage` report states credits against the plan alone and omits the overage section +/// entirely for organization accounts, so it can never show how much overage has been spent or how +/// much remains. `GetUsageLimits` carries the overage allowance on top of the plan, which is the +/// ceiling an account actually spends against. +public struct KiroUsageLimits: Codable, Equatable, Sendable { + /// Credits included in the plan. + public let planLimit: Double + /// Plan credits spent, excluding overage. + public let planUsed: Double + /// Overage credits spent beyond the plan. + public let overageUsed: Double + /// Maximum overage credits the account may spend, or nil when overage is not enabled. + public let overageCap: Double? + /// `true`/`false` when the API stated ENABLED/DISABLED; `nil` when omitted, unrecognized, or ENABLED without a cap. + public let overageEnabled: Bool? + /// Charges accrued for `overageUsed`, in `currencyCode`. + public let overageCharges: Double? + /// Price per overage credit, in `currencyCode`. + public let overageRate: Double? + public let currencyCode: String + public let resetsAt: Date + /// True when `bonuses[]` was non-empty, so plan usage cannot be split from bonus spend. + public let hasUnseparatedBonus: Bool + + public init( + planLimit: Double, + planUsed: Double, + overageUsed: Double, + overageCap: Double?, + overageEnabled: Bool? = nil, + overageCharges: Double?, + overageRate: Double?, + currencyCode: String, + resetsAt: Date, + hasUnseparatedBonus: Bool = false) + { + self.planLimit = planLimit + self.planUsed = planUsed + self.overageUsed = overageUsed + self.overageCap = overageCap + self.overageEnabled = overageEnabled + self.overageCharges = overageCharges + self.overageRate = overageRate + self.currencyCode = currencyCode + self.resetsAt = resetsAt + self.hasUnseparatedBonus = hasUnseparatedBonus + } + + /// The overage budget in currency terms, used as the denominator for accrued charges. + public var overageChargeLimit: Double? { + guard let overageCap, let overageRate, overageCap > 0, overageRate > 0 else { return nil } + return overageCap * overageRate + } +} + +public enum KiroUsageLimitsError: LocalizedError, Sendable { + case credentialsUnavailable(String) + case requestFailed(String) + case parseError(String) + + public var errorDescription: String? { + switch self { + case let .credentialsUnavailable(message): + "Kiro CLI credentials unavailable: \(message)" + case let .requestFailed(message): + "Kiro usage API request failed: \(message)" + case let .parseError(message): + "Failed to parse Kiro usage API response: \(message)" + } + } +} + +public enum KiroUsageLimitsAPI: Sendable { + /// Endpoint the official CLI resolves for `codewhispererruntime`. + static let defaultEndpoint = URL(string: "https://codewhisperer.us-east-1.amazonaws.com/")! + private static let target = "AmazonCodeWhispererService.GetUsageLimits" + private static let contentType = "application/x-amz-json-1.0" + private static let creditResource = "CREDIT" + private static let overageEnabled = "ENABLED" + private static let overageDisabled = "DISABLED" + private static let requestTimeout: TimeInterval = 10 + /// Plausible Unix seconds for a billing reset: 2001-09-09 through 2100-01-01. A value outside + /// this range is a unit change, not a date — milliseconds would land far beyond any real reset. + private static let resetRange: ClosedRange = 1_000_000_000...4_102_444_800 + + private static let logger = CodexBarLog.logger(LogCategories.provider(.kiro, scope: "usage-api")) + + public static func stateDatabaseURL( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment, + usesMacOSApplicationSupport: Bool = { + #if os(macOS) + true + #else + false + #endif + }()) -> URL + { + if let override = self.cleanedPath(environment["KIRO_DATA_DIR"]) { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("data.sqlite3", isDirectory: false) + } + if usesMacOSApplicationSupport { + return homeDirectory + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("kiro-cli", isDirectory: true) + .appendingPathComponent("data.sqlite3", isDirectory: false) + } + let dataHome = self.cleanedPath(environment["XDG_DATA_HOME"]).map { + URL(fileURLWithPath: $0, isDirectory: true) + } ?? homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + return dataHome + .appendingPathComponent("kiro-cli", isDirectory: true) + .appendingPathComponent("data.sqlite3", isDirectory: false) + } + + private static func cleanedPath(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return (trimmed as NSString).expandingTildeInPath + } + + public static func fetch( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) async throws -> KiroUsageLimits + { + try await self.fetch( + databaseURL: self.stateDatabaseURL(homeDirectory: homeDirectory), + endpoint: self.defaultEndpoint, + transport: self.isolatedTransport) + } + + static func fetch( + databaseURL: URL, + endpoint: URL, + transport: any ProviderHTTPTransport) async throws -> KiroUsageLimits + { + let identity = try self.readIdentity(databaseURL: databaseURL) + var request = URLRequest(url: endpoint, timeoutInterval: self.requestTimeout) + request.httpMethod = "POST" + request.setValue(self.contentType, forHTTPHeaderField: "Content-Type") + request.setValue(self.target, forHTTPHeaderField: "X-Amz-Target") + request.setValue("Bearer \(identity.accessToken)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data( + withJSONObject: ["profileArn": identity.profileARN]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await transport.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch { + if Task.isCancelled || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + throw KiroUsageLimitsError.requestFailed(error.localizedDescription) + } + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw KiroUsageLimitsError.requestFailed("HTTP \(http.statusCode)") + } + return try self.parse(data) + } + + static func parse(_ data: Data) throws -> KiroUsageLimits { + let response: UsageLimitsResponse + do { + response = try JSONDecoder().decode(UsageLimitsResponse.self, from: data) + } catch { + throw KiroUsageLimitsError.parseError(error.localizedDescription) + } + + let credits = response.usageBreakdownList.filter { $0.resourceType == self.creditResource } + guard let credit = credits.first else { + throw KiroUsageLimitsError.parseError("no credit balance reported") + } + guard credits.count == 1 else { + throw KiroUsageLimitsError.parseError("several credit balances reported") + } + + // Validate each component rather than the sum: a negative one would otherwise hide inside a + // positive total and produce an authoritative wrong percentage. + let planLimit = try self.usableCredits(credit.usageLimitWithPrecision, field: "plan limit") + let totalUsed = try self.usableCredits(credit.currentUsageWithPrecision, field: "usage") + let overageUsed = try self.usableCredits( + credit.currentOveragesWithPrecision ?? 0, + field: "overage usage") + // `currentUsage` is the total including overage. An overage larger than that total is + // relationally impossible, and clamping it to zero would overwrite valid CLI plan usage. + guard totalUsed >= overageUsed else { + throw KiroUsageLimitsError.parseError("overage exceeds total usage") + } + let planUsed = totalUsed - overageUsed + let hasUnseparatedBonus = !(credit.bonuses ?? []).isEmpty + // Bonus spend is folded into currentUsage, so planUsed can exceed the plan ceiling. + if !hasUnseparatedBonus { + guard planUsed <= planLimit else { + throw KiroUsageLimitsError.parseError("plan usage exceeds plan limit") + } + } + + let overageAvailability = self.overageAvailability(response.overageConfiguration?.overageStatus) + let overageCap: Double? = if overageAvailability == true, let cap = credit.overageCapWithPrecision { + try self.usableCredits(cap, field: "overage cap") + } else { + nil + } + // ENABLED without a cap is incomplete, not disabled — keep CLI overage rows visible. + let overageEnabled: Bool? = overageAvailability == true && overageCap == nil ? nil : overageAvailability + guard let resetsAt = self.resetDate(credit.nextDateReset ?? response.nextDateReset) else { + throw KiroUsageLimitsError.parseError("no plausible reset date reported") + } + + return KiroUsageLimits( + planLimit: planLimit, + planUsed: planUsed, + overageUsed: overageUsed, + overageCap: overageCap, + overageEnabled: overageEnabled, + overageCharges: credit.overageCharges.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }, + overageRate: credit.overageRate.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }, + currencyCode: credit.currency ?? "USD", + resetsAt: resetsAt, + hasUnseparatedBonus: hasUnseparatedBonus) + } + + private static func overageAvailability(_ status: String?) -> Bool? { + guard let status, !status.isEmpty else { return nil } + switch status.uppercased() { + case self.overageEnabled: + return true + case self.overageDisabled: + return false + default: + return nil + } + } + + private static func usableCredits(_ value: Double, field: String) throws -> Double { + guard value.isFinite, value >= 0 else { + throw KiroUsageLimitsError.parseError("no usable \(field)") + } + return value + } + + private static func resetDate(_ value: Double?) -> Date? { + guard let value, value.isFinite, self.resetRange.contains(value) else { return nil } + return Date(timeIntervalSince1970: value) + } + + private static let isolatedTransport: any ProviderHTTPTransport = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + return ProviderHTTPClient(session: ProviderHTTPClient.redirectGuardedSession(configuration: configuration)) + }() + + // MARK: - Response shape + + private struct UsageLimitsResponse: Decodable { + let usageBreakdownList: [UsageBreakdown] + let overageConfiguration: OverageConfiguration? + let nextDateReset: Double? + } + + private struct UsageBreakdown: Decodable { + let resourceType: String + let currentUsageWithPrecision: Double + let usageLimitWithPrecision: Double + let currentOveragesWithPrecision: Double? + let overageCapWithPrecision: Double? + let overageCharges: Double? + let overageRate: Double? + let currency: String? + let nextDateReset: Double? + let bonuses: [BonusEntry]? + + struct BonusEntry: Decodable {} + } + + private struct OverageConfiguration: Decodable { + let overageStatus: String + } + + // MARK: - CLI credentials + + private struct KiroCLIIdentity { + let accessToken: String + let profileARN: String + } + + /// Reads the CLI's credentials without disturbing them: the CLI owns the token and its refresh, + /// so this connection is read-only. + /// + /// A test that reaches this without opting in would resolve the developer's real state database + /// and call the live service with their token, so tests must name their own database instead. + private static func readIdentity(databaseURL: URL) throws -> KiroCLIIdentity { + #if canImport(SQLite3) || canImport(CSQLite3) + if ProviderHTTPClient.isRunningTests, databaseURL == self.stateDatabaseURL() { + throw KiroUsageLimitsError.credentialsUnavailable( + "usage API needs an explicit state database under tests") + } + guard FileManager.default.isReadableFile(atPath: databaseURL.path) else { + throw KiroUsageLimitsError.credentialsUnavailable("Kiro CLI state database not readable") + } + var db: OpaquePointer? + let openResult = sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) + guard openResult == SQLITE_OK else { + let message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw KiroUsageLimitsError.credentialsUnavailable("open state database: \(message)") + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let tokenJSON = try self.queryValue( + db: db, + sql: "SELECT value FROM auth_kv WHERE key = 'kirocli:odic:token'", + what: "token") + let profileJSON = try self.queryValue( + db: db, + sql: "SELECT value FROM state WHERE key = 'api.codewhisperer.profile'", + what: "profile") + + guard let accessToken = self.jsonString(in: tokenJSON, key: "access_token") else { + throw KiroUsageLimitsError.credentialsUnavailable("token has no access_token") + } + guard let profileARN = self.jsonString(in: profileJSON, key: "arn") else { + throw KiroUsageLimitsError.credentialsUnavailable("profile has no arn") + } + return KiroCLIIdentity(accessToken: accessToken, profileARN: profileARN) + #else + throw KiroUsageLimitsError.credentialsUnavailable("SQLite unavailable on this platform") + #endif + } + + #if canImport(SQLite3) || canImport(CSQLite3) + private static func queryValue(db: OpaquePointer?, sql: String, what: String) throws -> String { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + let message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw KiroUsageLimitsError.credentialsUnavailable("read \(what): \(message)") + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW, let text = sqlite3_column_text(statement, 0) else { + throw KiroUsageLimitsError.credentialsUnavailable("\(what) not found in Kiro CLI state") + } + return String(cString: text) + } + #endif + + private static func jsonString(in json: String, key: String) -> String? { + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = object[key] as? String, + !value.isEmpty + else { return nil } + return value + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index 1324e7436..a17609641 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -2,13 +2,26 @@ import Foundation public enum OpenCodeGoProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() - private static let credentials = ProviderCredentialAdapter(tokenAccountSupport: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple OpenCode Go Cookie headers.", - placeholder: "Cookie: …", - injection: .cookieHeader, - requiresManualCookieSource: true, - cookieName: nil)) + private static let credentials = ProviderCredentialAdapter( + supportsAPIKeyOverride: true, + apiKeyDebugLabel: OpenCodeGoSettingsReader.apiKeyEnvironmentKey, + environmentProjections: [.apiKey(OpenCodeGoSettingsReader.apiKeyEnvironmentKey)], + tokenResolver: { kind, environment, _ in + guard kind == .primary, + let token = OpenCodeGoSettingsReader.apiKey(environment: environment) + else { return nil } + return ProviderTokenResolution(token: token, source: .environment) + }, + tokenAccountSupport: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple OpenCode Go Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), + authDetector: { environment, _ in + OpenCodeGoSettingsReader.apiKey(environment: environment) == nil ? [] : ["api"] + }) static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( @@ -95,7 +108,7 @@ public enum OpenCodeGoProviderDescriptor { }, supportsInlineTokenCostDashboard: true)), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web], + sourceModes: [.auto, .api, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "opencodego", @@ -106,6 +119,9 @@ public enum OpenCodeGoProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + if context.sourceMode == .api { + return [OpenCodeGoAPIUsageFetchStrategy()] + } if context.sourceMode == .web { return [OpenCodeGoUsageFetchStrategy()] } @@ -113,10 +129,12 @@ public enum OpenCodeGoProviderDescriptor { return [ OpenCodeGoUsageFetchStrategy(), OpenCodeGoLocalUsageFetchStrategy(), + OpenCodeGoAPIUsageFetchStrategy(), ] } return [ OpenCodeGoLocalUsageFetchStrategy(), + OpenCodeGoAPIUsageFetchStrategy(), OpenCodeGoUsageFetchStrategy(), ] } @@ -152,9 +170,12 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { typealias LocalSnapshotLoader = @Sendable (ProviderFetchContext) throws -> OpenCodeGoUsageSnapshot typealias WebUsageOverlayFetcher = @Sendable (ProviderFetchContext, String) async throws -> OpenCodeGoUsageSnapshot? + typealias APIUsageOverlayFetcher = @Sendable (ProviderFetchContext, String) async throws + -> OpenCodeGoUsageSnapshot private let localSnapshotLoader: LocalSnapshotLoader private let webUsageOverlayFetcher: WebUsageOverlayFetcher + private let apiUsageOverlayFetcher: APIUsageOverlayFetcher private struct OverlayCookie { let header: String @@ -163,7 +184,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { private struct SnapshotResult { let snapshot: OpenCodeGoUsageSnapshot - let webUsageApplied: Bool + let sourceLabel: String let quotaIsAuthoritative: Bool } @@ -171,10 +192,16 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { localSnapshotLoader: @escaping LocalSnapshotLoader = { context in try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) }, - webUsageOverlayFetcher: @escaping WebUsageOverlayFetcher = Self.liveWebUsageOverlay) + webUsageOverlayFetcher: @escaping WebUsageOverlayFetcher = Self.liveWebUsageOverlay, + apiUsageOverlayFetcher: @escaping APIUsageOverlayFetcher = { context, apiKey in + try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: apiKey, + timeout: context.webTimeout) + }) { self.localSnapshotLoader = localSnapshotLoader self.webUsageOverlayFetcher = webUsageOverlayFetcher + self.apiUsageOverlayFetcher = apiUsageOverlayFetcher } func isAvailable(_: ProviderFetchContext) async -> Bool { @@ -186,7 +213,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { let usage = result.snapshot.toUsageSnapshot() return self.makeResult( usage: result.quotaIsAuthoritative ? usage : usage.withDataConfidence(.estimated), - sourceLabel: result.webUsageApplied ? "local+web" : "local") + sourceLabel: result.sourceLabel) } func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { @@ -195,10 +222,26 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { private func snapshot(context: ProviderFetchContext) async throws -> SnapshotResult { let snapshot = try self.localSnapshotLoader(context) + if let apiKey = OpenCodeGoSettingsReader.apiKey(environment: context.env) { + do { + let apiSnapshot = try await self.apiUsageOverlayFetcher(context, apiKey) + let apiOverlay = snapshot.applyingWebUsage(apiSnapshot) + return try await SnapshotResult( + snapshot: self.preservingCookieBalance(in: apiOverlay, context: context), + sourceLabel: "local+api", + quotaIsAuthoritative: true) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + // Keep the existing cookie path as a compatibility fallback. + } + } guard context.settings?.opencodego?.cookieSource != .off, let cookie = Self.cachedOrManualCookie(context: context) else { - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } // The server knows the real billing-cycle anchors; the local monthly window is only an @@ -213,7 +256,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { #if os(macOS) if let cached = cookie.cachedEntry { _ = CookieHeaderCache.clearIfCurrent(provider: .opencodego, expected: cached) - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } #endif // A manually configured credential is an explicit account selection. Do not hide its @@ -224,17 +267,17 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } catch let error as URLError where error.code == .cancelled { throw CancellationError() } catch { - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } if let webSnapshot { return SnapshotResult( snapshot: snapshot.applyingWebUsage(webSnapshot), - webUsageApplied: true, + sourceLabel: "local+web", quotaIsAuthoritative: !webSnapshot.isBalanceOnly) } guard context.includeOptionalUsage else { - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } let workspaceOverride = context.settings?.opencodego?.workspaceID ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] @@ -258,10 +301,39 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { waitForZenBalance: OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(context: context))) return SnapshotResult( snapshot: snapshot.withZenBalanceUSD(zenBalance), - webUsageApplied: false, + sourceLabel: "local", quotaIsAuthoritative: false) } + private func preservingCookieBalance( + in snapshot: OpenCodeGoUsageSnapshot, + context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot + { + guard context.settings?.opencodego?.cookieSource != .off, + let cookie = Self.cachedOrManualCookie(context: context) + else { return snapshot } + + do { + guard let webSnapshot = try await self.webUsageOverlayFetcher(context, cookie.header) else { + return snapshot + } + return snapshot.withZenBalanceUSD(webSnapshot.zenBalanceUSD ?? snapshot.zenBalanceUSD) + } catch OpenCodeGoUsageError.invalidCredentials { + #if os(macOS) + if let cached = cookie.cachedEntry { + _ = CookieHeaderCache.clearIfCurrent(provider: .opencodego, expected: cached) + } + #endif + return snapshot + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + return snapshot + } + } + static func liveWebUsageOverlay( context: ProviderFetchContext, cookieHeader: String) async throws -> OpenCodeGoUsageSnapshot? @@ -309,6 +381,32 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } } +struct OpenCodeGoAPIUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = OpenCodeGoSettingsReader.apiKey(environment: context.env) else { + throw OpenCodeGoSettingsError.missingAPIKey + } + let snapshot = try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: apiKey, + timeout: context.webTimeout) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + if error is CancellationError { return false } + if let urlError = error as? URLError, urlError.code == .cancelled { return false } + return true + } +} + struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { let id: String = "opencodego.web" let kind: ProviderFetchKind = .web @@ -392,11 +490,14 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { } enum OpenCodeGoSettingsError: LocalizedError { + case missingAPIKey case missingCookie case invalidCookie var errorDescription: String? { switch self { + case .missingAPIKey: + "No OpenCode Go API key configured. Set OPENCODE_API_KEY or add apiKey to the QuotaKit config." case .missingCookie: "No OpenCode Go session cookies found in browsers." case .invalidCookie: diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoSettingsReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoSettingsReader.swift new file mode 100644 index 000000000..6df7e2f76 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoSettingsReader.swift @@ -0,0 +1,20 @@ +import Foundation + +public enum OpenCodeGoSettingsReader { + public static let apiKeyEnvironmentKey = "OPENCODE_API_KEY" + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + guard var value = environment[self.apiKeyEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { return nil } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index b01ee8914..d5c0d7a48 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -12,7 +12,7 @@ public enum OpenCodeGoUsageError: LocalizedError { public var errorDescription: String? { switch self { case .invalidCredentials: - "OpenCode Go session cookie is invalid or expired." + "OpenCode Go credentials are invalid or expired." case let .networkError(message): "OpenCode Go network error: \(message)" case let .apiError(message): @@ -28,6 +28,7 @@ public struct OpenCodeGoUsageFetcher: Sendable { private static let baseURL = URL(string: "https://opencode.ai")! private static let authURL = URL(string: "https://opencode.ai/auth")! private static let serverURL = URL(string: "https://opencode.ai/_server")! + private static let usageAPIURL = URL(string: "https://opencode.ai/zen/go/v1/usage")! private static let workspacesServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" private static let billingServerID = "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d" @@ -204,6 +205,41 @@ public struct OpenCodeGoUsageFetcher: Sendable { return snapshot.withZenBalanceUSD(zenBalance) } + public static func fetchAPIUsage( + apiKey: String, + timeout: TimeInterval, + now: Date = Date(), + session: URLSession? = nil) async throws -> OpenCodeGoUsageSnapshot + { + let token = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + throw OpenCodeGoSettingsError.missingAPIKey + } + + var request = URLRequest(url: self.usageAPIURL) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("QuotaKit", forHTTPHeaderField: "User-Agent") + + let response = try await (session ?? self.redirectGuardSession).response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw OpenCodeGoUsageError.invalidCredentials + } + let body = String(data: response.data, encoding: .utf8) ?? "" + if let message = self.extractServerErrorMessage(from: body) { + throw OpenCodeGoUsageError.apiError("HTTP \(response.statusCode): \(message)") + } + throw OpenCodeGoUsageError.apiError("HTTP \(response.statusCode)") + } + guard let text = String(data: response.data, encoding: .utf8) else { + throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") + } + return try self.parseSubscription(text: text, now: now) + } + static func requiredZenBalanceFallback( from task: Task?, for error: OpenCodeGoUsageError, diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 60b4dbcb5..0384b9b80 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -54,6 +54,9 @@ public struct ProviderFetchContext: Sendable { /// hosts set this beyond their refresh cadence so a slow cold start can /// recover on the next refresh. public let persistentCLISessionIdleWindow: TimeInterval? + /// Already-resolved CLI version from Settings (or the CLI's shared detector). + /// Codex PAT User-Agent consumes this instead of spawning `codex --version`. + public let resolvedCLIVersion: String? public init( runtime: ProviderRuntime, @@ -75,7 +78,8 @@ public struct ProviderFetchContext: Sendable { costUsageHistoryDays: Int = 30, claudeOwnerCLIRecoveryOnly: Bool = false, persistsCLISessions: Bool = false, - persistentCLISessionIdleWindow: TimeInterval? = nil) + persistentCLISessionIdleWindow: TimeInterval? = nil, + resolvedCLIVersion: String? = nil) { self.runtime = runtime self.sourceMode = sourceMode @@ -97,6 +101,7 @@ public struct ProviderFetchContext: Sendable { self.claudeOwnerCLIRecoveryOnly = claudeOwnerCLIRecoveryOnly self.persistsCLISessions = persistsCLISessions self.persistentCLISessionIdleWindow = persistentCLISessionIdleWindow + self.resolvedCLIVersion = resolvedCLIVersion } } @@ -106,6 +111,11 @@ public enum ProviderCLISessionLifecycle { } } +public enum CodexPATCredentialOwner: Sendable, Equatable { + case scopedCodexHome(path: String) + case ambientCodexHome +} + public struct ProviderFetchResult: Sendable { public let usage: UsageSnapshot public let credits: CreditsSnapshot? @@ -117,6 +127,11 @@ public struct ProviderFetchResult: Sendable { /// winning in-memory credential snapshot. Generic enrichment must not reload auth.json after /// that attempt fails, or it could attach another account's credits to this usage result. public let codexResetCreditsAttempted: Bool + /// Transient routing evidence for the credential selected by the Codex PAT strategy. + /// This value never enters persisted usage or sync payloads. + public let codexPATCredentialOwner: CodexPATCredentialOwner? + /// A non-secret Fireworks account slug discovered by the winning request. The app owns persistence. + public let fireworksDiscoveredAccountSlug: String? /// Optional live diagnostic retained alongside an otherwise usable snapshot. public let diagnostic: String? /// Transient account ownership evidence for plan-utilization history. @@ -142,6 +157,8 @@ public struct ProviderFetchResult: Sendable { strategyID: String, strategyKind: ProviderFetchKind, codexResetCreditsAttempted: Bool = false, + codexPATCredentialOwner: CodexPATCredentialOwner? = nil, + fireworksDiscoveredAccountSlug: String? = nil, diagnostic: String? = nil, claudeOAuthKeychainPersistentRefHash: String? = nil, claudeOAuthHistoryOwnerIdentifier: String? = nil, @@ -157,6 +174,8 @@ public struct ProviderFetchResult: Sendable { self.strategyID = strategyID self.strategyKind = strategyKind self.codexResetCreditsAttempted = codexResetCreditsAttempted + self.codexPATCredentialOwner = codexPATCredentialOwner + self.fireworksDiscoveredAccountSlug = fireworksDiscoveredAccountSlug self.diagnostic = diagnostic self.claudeOAuthKeychainPersistentRefHash = claudeOAuthKeychainPersistentRefHash self.claudeOAuthHistoryOwnerIdentifier = claudeOAuthHistoryOwnerIdentifier @@ -253,7 +272,8 @@ extension ProviderFetchStrategy { credits: CreditsSnapshot? = nil, dashboard: OpenAIDashboardSnapshot? = nil, sourceLabel: String, - diagnostic: String? = nil) -> ProviderFetchResult + diagnostic: String? = nil, + fireworksDiscoveredAccountSlug: String? = nil) -> ProviderFetchResult { ProviderFetchResult( usage: usage, @@ -262,6 +282,7 @@ extension ProviderFetchStrategy { sourceLabel: sourceLabel, strategyID: self.id, strategyKind: self.kind, + fireworksDiscoveredAccountSlug: fireworksDiscoveredAccountSlug, diagnostic: diagnostic) } } diff --git a/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift b/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift index c00d89a46..2ba38be69 100644 --- a/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/XAI/XAIBillingFetcher.swift @@ -66,12 +66,14 @@ public enum XAIBillingFetcher { // so only credential problems (and cancellation) are allowed to escalate. var daily: [XAIUsageSnapshot.DailyBucket] = [] var limitReached = false + var historyAvailable = false do { (daily, limitReached) = try await self.fetchDailyUsage( key: key, teamID: teamID, transport: transport, now: now) + historyAvailable = true } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { @@ -89,6 +91,7 @@ public enum XAIBillingFetcher { daily: daily, historyDays: self.historyDays, limitReached: limitReached, + historyAvailable: historyAvailable, updatedAt: now) } diff --git a/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift b/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift new file mode 100644 index 000000000..dbea99b8a --- /dev/null +++ b/Sources/CodexBarCore/Providers/XAI/XAICostUsageMapping.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum XAICostUsageMapping { + public static let historyDays = 30 + private static let dayPattern = #"^\d{4}-\d{2}-\d{2}$"# + + /// True when prepaid balance arrived but `/usage` history did not. + public static func isAnalyticsUnavailable(_ snapshot: UsageSnapshot) -> Bool { + !snapshot.details.contains { $0.chart != nil } + } + + /// Maps the Management API daily-spend chart onto the shared spend catalog. + /// Prepaid ledger balance is remaining credit, not spend, so it is never used here. + public static func tokenSnapshot(from snapshot: UsageSnapshot, historyDays _: Int) -> CostUsageTokenSnapshot? { + guard let chart = snapshot.details.compactMap(\.chart).first else { return nil } + let entries = chart.points + .compactMap { point -> CostUsageDailyReport.Entry? in + guard point.label.range(of: self.dayPattern, options: .regularExpression) != nil, + point.value.isFinite, + point.value >= 0 + else { return nil } + return CostUsageDailyReport.Entry( + date: point.label, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: point.value, + modelsUsed: nil, + modelBreakdowns: nil) + } + .sorted { $0.date < $1.date } + let total = entries.compactMap(\.costUSD).reduce(0, +) + guard total.isFinite else { return nil } + let today = self.utcDayKey(snapshot.updatedAt) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: entries.first { $0.date == today }?.costUSD, + last30DaysTokens: nil, + last30DaysCostUSD: total, + historyDays: Self.historyDays, + historyCoverageIsEstablished: snapshot.dataConfidence != .estimated, + historyLabel: snapshot.dataConfidence == .estimated ? "Last 30 days (partial)" : nil, + meteredCostUSD: total, + costProvenance: .vendorMetered, + daily: entries, + updatedAt: snapshot.updatedAt) + } + + static func utcDayKey(_ date: Date) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } +} diff --git a/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift index f3cd6f22f..50170efa1 100644 --- a/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/XAI/XAIProviderDescriptor.swift @@ -69,8 +69,11 @@ public enum XAIProviderDescriptor { ProviderColor(hex: 0xF5F5F7), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "xAI spend history comes from the Management API billing endpoints." }), + supportsTokenCost: true, + noDataMessage: { + "xAI daily spend requires a Management API key and team ID. " + + "Prepaid balance is not treated as spend." + }), presentation: ProviderUsagePresentation( identityPresenter: { provider, snapshot in guard let plan = snapshot.loginMethod(for: provider), !plan.isEmpty else { diff --git a/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift index 183f294dc..d3cd85f86 100644 --- a/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/XAI/XAIUsageSnapshot.swift @@ -29,6 +29,9 @@ public struct XAIUsageSnapshot: Codable, Equatable, Sendable { /// True when the usage endpoint reported its cardinality cap; daily sums /// may then be incomplete and must not be presented as exact. public let limitReached: Bool + /// True only when the usage-history request completed successfully. An + /// empty successful response is known zero; failed enrichment is unknown. + public let historyAvailable: Bool public let updatedAt: Date public init( @@ -36,15 +39,50 @@ public struct XAIUsageSnapshot: Codable, Equatable, Sendable { daily: [DailyBucket], historyDays: Int = 30, limitReached: Bool = false, + historyAvailable: Bool = true, updatedAt: Date) { self.balanceUSD = balanceUSD self.daily = daily.sorted { $0.day < $1.day } self.historyDays = max(1, min(365, historyDays)) self.limitReached = limitReached + self.historyAvailable = historyAvailable self.updatedAt = updatedAt } + private enum CodingKeys: String, CodingKey { + case balanceUSD + case daily + case historyDays + case limitReached + case historyAvailable + case updatedAt + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let daily = try container.decode([DailyBucket].self, forKey: .daily) + self.balanceUSD = try container.decode(Double.self, forKey: .balanceUSD) + self.daily = daily.sorted { $0.day < $1.day } + self.historyDays = try max(1, min(365, container.decode(Int.self, forKey: .historyDays))) + self.limitReached = try container.decode(Bool.self, forKey: .limitReached) + // Legacy snapshots had no availability bit. Non-empty history proves success; + // legacy empty history stays unavailable rather than being promoted to $0. + self.historyAvailable = try container.decodeIfPresent(Bool.self, forKey: .historyAvailable) + ?? !daily.isEmpty + self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.balanceUSD, forKey: .balanceUSD) + try container.encode(self.daily, forKey: .daily) + try container.encode(self.historyDays, forKey: .historyDays) + try container.encode(self.limitReached, forKey: .limitReached) + try container.encode(self.historyAvailable, forKey: .historyAvailable) + try container.encode(self.updatedAt, forKey: .updatedAt) + } + public var historyWindowPeriodLabel: String { let base = self.historyDays == 1 ? "Today" : "Last \(self.historyDays) days" return self.limitReached ? "\(base) (partial)" : base @@ -72,10 +110,10 @@ public struct XAIUsageSnapshot: Codable, Equatable, Sendable { label: self.historyWindowPeriodLabel, value: UsageFormatter.usdString(self.windowCostUSD)), ], - chart: self.daily.isEmpty ? nil : .makeChart( + chart: self.historyAvailable ? .makeChart( title: "Daily spend", unit: "USD", - points: self.daily.map { ($0.day, $0.costUSD) }))], + points: self.daily.map { ($0.day, $0.costUSD) }) : nil)], xaiUsage: self, updatedAt: self.updatedAt, identity: ProviderIdentitySnapshot( @@ -86,10 +124,10 @@ public struct XAIUsageSnapshot: Codable, Equatable, Sendable { dataConfidence: self.limitReached ? .estimated : .exact) } - /// Nil when no history came back: the inline dashboard should fall through - /// instead of charting an empty series as if the team genuinely spent $0. + /// Nil only when history was unavailable. A successful empty response is a + /// confirmed zero and intentionally projects an empty daily series. public func costHistorySnapshot() -> CostUsageTokenSnapshot? { - guard !self.daily.isEmpty else { return nil } + guard self.historyAvailable else { return nil } let entries = self.daily.map { bucket in CostUsageDailyReport.Entry( date: bucket.day, @@ -103,7 +141,7 @@ public struct XAIUsageSnapshot: Codable, Equatable, Sendable { let today = self.daily.first { $0.day == Self.utcDayString(from: self.updatedAt) } return CostUsageTokenSnapshot( sessionTokens: nil, - sessionCostUSD: today?.costUSD ?? 0, + sessionCostUSD: today?.costUSD, last30DaysTokens: nil, last30DaysCostUSD: self.windowCostUSD, historyDays: self.historyDays, diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index b6ff36a23..abe89fd72 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -50,11 +50,23 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { URL(string: "https://bigmodel.cn/coding-plan/team/usage-stats")! } } + + /// BigModel CN pay-as-you-go account balance. The endpoint lives on the + /// `www.bigmodel.cn` console host (not the `open.` API host) and accepts both + /// `Bearer ` and raw-key Authorization (verified 2026-08). z.ai global has + /// no documented equivalent. + public var balanceURL: URL? { + switch self { + case .global: nil + case .bigmodelCN: URL(string: "https://www.bigmodel.cn/api/biz/account/query-customer-account-report")! + } + } } public enum ZaiEndpointRouter { private static let quotaPath = "api/monitor/usage/quota/limit" private static let modelUsagePath = "api/monitor/usage/model-usage" + public static let balancePath = "api/biz/account/query-customer-account-report" public static func resolveQuotaURL( region: ZaiAPIRegion, @@ -83,6 +95,19 @@ public enum ZaiEndpointRouter { return region.modelUsageURL } + /// Balance is a BigModel CN-only feature; returns nil for the global region so the + /// plugin skips the extra request entirely. + public static func resolveBalanceURL( + region: ZaiAPIRegion, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + if let override = ZaiSettingsReader.balanceURL(environment: environment) { + return override + } + guard region == .bigmodelCN else { return nil } + return region.balanceURL + } + public static func resolveDashboardURL( region: ZaiAPIRegion, environment: [String: String] = ProcessInfo.processInfo.environment, diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index bd2a74c1c..f72c3f80d 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -155,6 +155,21 @@ public enum ZaiProviderDescriptor { provider: .zai, bundledPlugin: "zai", secretKey: ZaiSettingsReader.apiTokenKey, + sourceLabel: "api", + validateContext: { context in + let settings = context.settings?.zai + let region = settings?.apiRegion ?? .global + try ZaiSettingsReader.validateEndpointOverrides( + region: region, + environment: context.env) + let scope = settings?.usageScope ?? .personal + if scope == .team, + settings?.teamContext == nil, + ZaiBigModelTeamContext(environment: context.env) == nil + { + throw ZaiProviderSettingsError.missingTeamContext + } + }, resolveValues: { context in let settings = context.settings?.zai let region = settings?.apiRegion ?? .global @@ -165,8 +180,20 @@ public enum ZaiProviderDescriptor { var plainValues = [ "Z_AI_REGION": region.rawValue, "Z_AI_USAGE_SCOPE": (settings?.usageScope ?? .personal).rawValue, + "Z_AI_QUOTA_ENDPOINT": ZaiEndpointRouter.resolveQuotaURL( + region: region, + environment: context.env).absoluteString, + "Z_AI_MODEL_USAGE_ENDPOINT": ZaiEndpointRouter.resolveModelUsageURL( + region: region, + environment: context.env).absoluteString, ] - if let team = settings?.teamContext { + if let balanceURL = ZaiEndpointRouter.resolveBalanceURL( + region: region, + environment: context.env) + { + plainValues["Z_AI_BALANCE_ENDPOINT"] = balanceURL.absoluteString + } + if let team = settings?.teamContext ?? ZaiBigModelTeamContext(environment: context.env) { plainValues["Z_AI_ORGANIZATION"] = team.organizationID plainValues["Z_AI_PROJECT"] = team.projectID } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift index d3dbc43d7..de2237682 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift @@ -17,6 +17,7 @@ public struct ZaiSettingsReader: Sendable { ] public static let apiHostKey = "Z_AI_API_HOST" public static let quotaURLKey = "Z_AI_QUOTA_URL" + public static let balanceURLKey = "Z_AI_BALANCE_URL" public static let bigModelOrganizationKey = "Z_AI_BIGMODEL_ORGANIZATION" public static let bigModelProjectKey = "Z_AI_BIGMODEL_PROJECT" @@ -64,11 +65,19 @@ public struct ZaiSettingsReader: Sendable { return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) } + public static func balanceURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + guard let raw = self.cleaned(environment[balanceURLKey]) else { return nil } + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) + } + public static func validateEndpointOverrides( environment: [String: String] = ProcessInfo.processInfo.environment) throws { try self.validateQuotaEndpointOverride(environment: environment) try self.validateAPIHostEndpointOverride(environment: environment) + try self.validateBalanceEndpointOverride(environment: environment) } public static func validateEndpointOverrides( @@ -77,6 +86,20 @@ public struct ZaiSettingsReader: Sendable { { try self.validateQuotaEndpointOverride(region: region, environment: environment) try self.validateAPIHostEndpointOverride(region: region, environment: environment) + try self.validateBalanceEndpointOverride(environment: environment) + } + + /// A malformed or non-HTTPS `Z_AI_BALANCE_URL` must reject the fetch (mirroring the + /// quota/host overrides) instead of silently falling back to the production endpoint. + /// Shared by both `validateEndpointOverrides` overloads so the region-aware path used + /// by the provider fetch pipeline validates it too. + static func validateBalanceEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard self.cleaned(environment[self.balanceURLKey]) != nil else { return } + guard self.balanceURL(environment: environment) != nil else { + throw ZaiSettingsError.invalidEndpointOverride(self.balanceURLKey) + } } public static func validateQuotaEndpointOverride( diff --git a/Sources/CodexBarCore/Resources/Plugins/xai.js b/Sources/CodexBarCore/Resources/Plugins/xai.js index dd8482a93..a545ad47b 100644 --- a/Sources/CodexBarCore/Resources/Plugins/xai.js +++ b/Sources/CodexBarCore/Resources/Plugins/xai.js @@ -38,6 +38,7 @@ defineProvider({ const timestamp = date => `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")} ${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}:${String(date.getUTCSeconds()).padStart(2, "0")}`; let daily = []; let partial = false; + let historyAvailable = false; try { const usage = await ctx.http.postJSON(`${root}/usage`, { body: { analyticsRequest: { timeRange: { startTime: timestamp(start), endTime: timestamp(now), timezone: "Etc/GMT" }, @@ -48,18 +49,29 @@ defineProvider({ throw ctx.fail.authenticationExpired("xAI rejected the Management API key. Create one in the xAI Console under Settings > Management Keys; inference API keys are not accepted."); } if (usage.status >= 200 && usage.status < 300) { + if (!usage.json || !Array.isArray(usage.json.timeSeries)) { + throw new Error("invalid xAI usage history"); + } const totals = {}; - for (const series of usage.json.timeSeries || []) for (const point of series.dataPoints || []) { - const date = new Date(point.timestamp); - const value = (point.values || [0])[0] || 0; - if (!Number.isFinite(date.getTime()) || typeof value !== "number" || !Number.isFinite(value)) { + for (const series of usage.json.timeSeries) { + if (!series || !Array.isArray(series.dataPoints)) { throw new Error("invalid xAI usage history"); } - const day = date.toISOString().slice(0, 10); - totals[day] = (totals[day] || 0) + value; + for (const point of series.dataPoints) { + const date = new Date(point.timestamp); + const value = Array.isArray(point.values) ? point.values[0] : undefined; + if (!Number.isFinite(date.getTime()) || typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error("invalid xAI usage history"); + } + const day = date.toISOString().slice(0, 10); + totals[day] = (totals[day] || 0) + value; + } } - daily = Object.keys(totals).sort().map(day => ({ label: day, value: totals[day] })); + daily = Object.keys(totals) + .sort() + .map((day) => ({ label: day, value: totals[day] })); partial = usage.json.limitReached === true; + historyAvailable = true; } } catch (error) { if (/rejected the Management API key/.test(error.message)) throw error; @@ -68,17 +80,21 @@ defineProvider({ cost: { used: balance, currency: "USD", period: "Prepaid credits" }, identity: { loginMethod: "Management API" }, dataConfidence: partial ? "estimated" : "exact", - details: [{ - title: "Billing summary", - rows: [ - { label: "Prepaid balance", value: `$${balance.toFixed(2)}` }, - { - label: partial ? "Last 30 days (partial)" : "Last 30 days", - value: `$${daily.reduce((sum, point) => sum + point.value, 0).toFixed(2)}`, - }, - ], - chart: daily.length ? { kind: "bars", title: "Daily spend", unit: "USD", points: daily } : undefined, - }], + details: [ + { + title: "Billing summary", + rows: [ + { label: "Prepaid balance", value: `$${balance.toFixed(2)}` }, + { + label: partial ? "Last 30 days (partial)" : "Last 30 days", + value: `$${daily.reduce((sum, point) => sum + point.value, 0).toFixed(2)}`, + }, + ], + // Emit an empty chart on successful history so spend mapping can tell + // "zero days" from "analytics unavailable". + chart: historyAvailable ? { kind: "bars", title: "Daily spend", unit: "USD", points: daily } : undefined, + }, + ], }; }, }); diff --git a/Sources/CodexBarCore/Resources/Plugins/zai.js b/Sources/CodexBarCore/Resources/Plugins/zai.js index c9a0e27d3..20c38888f 100644 --- a/Sources/CodexBarCore/Resources/Plugins/zai.js +++ b/Sources/CodexBarCore/Resources/Plugins/zai.js @@ -4,8 +4,10 @@ defineProvider({ endpoints: [ "https://api.z.ai", "https://open.bigmodel.cn", + "https://www.bigmodel.cn", { setting: "Z_AI_QUOTA_ENDPOINT", policy: "https" }, { setting: "Z_AI_MODEL_USAGE_ENDPOINT", policy: "https" }, + { setting: "Z_AI_BALANCE_ENDPOINT", policy: "https" }, ], auth: { type: "bearer", secret: "Z_AI_API_KEY" }, settings: [ @@ -16,6 +18,7 @@ defineProvider({ { key: "Z_AI_PROJECT", title: "Project", type: "plain" }, { key: "Z_AI_QUOTA_ENDPOINT", title: "Quota endpoint", type: "plain" }, { key: "Z_AI_MODEL_USAGE_ENDPOINT", title: "Model usage endpoint", type: "plain" }, + { key: "Z_AI_BALANCE_ENDPOINT", title: "Balance endpoint", type: "plain" }, ], async fetchUsage(ctx) { @@ -201,6 +204,46 @@ defineProvider({ ); if (plan) result.identity.loginMethod = plan.trim(); + // BigModel CN pay-as-you-go account balance (www.bigmodel.cn console endpoint, + // verified 2026-08: accepts both "Bearer " and raw-key Authorization). + // z.ai global has no documented equivalent, so the row is CN-only. Best-effort — + // a failed balance lookup must never break quota display. + if (region === "bigmodel-cn") { + try { + const balanceEndpoint = + ctx.settings.get("Z_AI_BALANCE_ENDPOINT") || + "https://www.bigmodel.cn/api/biz/account/query-customer-account-report"; + // Optional lookup: bound it well below the fetch deadline so a stalling balance + // service can neither delay the later model-usage requests nor discard the + // already-fetched quota snapshot. + const response = await ctx.http.getJSON(balanceEndpoint, { timeoutSeconds: 5 }); + const body = response.json; + if (response.status === 200 && body && typeof body === "object" && body.success === true) { + const data = body.data && typeof body.data === "object" ? body.data : {}; + // Number(null) is 0, which would silently defeat the fallback below and + // render misleading ¥0.00 rows — only actual numeric values participate. + const numeric = (value) => (value === null || value === undefined ? undefined : Number(value)); + const available = numeric(data.availableBalance); + const current = numeric(data.balance); + const value = Number.isFinite(available) ? available : current; + if (Number.isFinite(value)) { + const recharged = numeric(data.rechargeAmount); + const granted = numeric(data.giveAmount); + const spent = numeric(data.totalSpendAmount); + const secondary = []; + if (Number.isFinite(recharged)) secondary.push(`recharged ¥${recharged.toFixed(2)}`); + if (Number.isFinite(granted) && granted > 0) secondary.push(`granted ¥${granted.toFixed(2)}`); + if (Number.isFinite(spent)) secondary.push(`spent ¥${spent.toFixed(2)}`); + result.details[0].rows.push({ + label: "Account balance", + value: `¥${Number(value).toFixed(2)}`, + secondaryValue: secondary.join(" · ") || undefined, + }); + } + } + } catch {} + } + async function modelUsage(daysBack) { const end = ctx.date.now(); const start = new Date(end); diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 42772c1d5..51d7d02cb 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -1105,7 +1105,7 @@ private final class CodexRPCClient: @unchecked Sendable { // Provider-specific by design: Codex RPC owns its dedicated subprocess log category. private static let log = CodexBarLog.logger(LogCategories.provider(.codex, scope: "rpc")) private let process = Process() - private let stdinPipe = Pipe() + private let stdin = RPCChildProcessInput() private let stdoutPipe = Pipe() private let stderrPipe = Pipe() private let stdoutLineStream: AsyncStream @@ -1147,7 +1147,7 @@ private final class CodexRPCClient: @unchecked Sendable { self.process.environment = env self.process.executableURL = URL(fileURLWithPath: "/usr/bin/env") self.process.arguments = [resolvedExec] + arguments - self.process.standardInput = self.stdinPipe + self.process.standardInput = self.stdin.pipe self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe @@ -1170,7 +1170,7 @@ private final class CodexRPCClient: @unchecked Sendable { let stdoutLineContinuation = self.stdoutLineContinuation let stdoutBuffer = BoundedLineBuffer() let process = self.process - let stdinPipe = self.stdinPipe + let stdin = self.stdin stdoutHandle.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { @@ -1184,7 +1184,7 @@ private final class CodexRPCClient: @unchecked Sendable { Self.log.warning("Codex RPC line exceeded memory limit; terminating process") handle.readabilityHandler = nil DispatchQueue.global(qos: .userInitiated).async { - RPCChildProcessTeardown.terminate(process: process, stdinPipe: stdinPipe) + RPCChildProcessTeardown.terminate(process: process, stdin: stdin) } stdoutLineContinuation.finish() return @@ -1231,7 +1231,7 @@ private final class CodexRPCClient: @unchecked Sendable { func shutdown() { Self.log.debug("Codex RPC stopping") - RPCChildProcessTeardown.terminate(process: self.process, stdinPipe: self.stdinPipe) + RPCChildProcessTeardown.terminate(process: self.process, stdin: self.stdin) } // MARK: - JSON-RPC helpers @@ -1310,9 +1310,9 @@ private final class CodexRPCClient: @unchecked Sendable { // Dispatch off the timeout task so the bounded TERM-to-KILL wait cannot delay the timeout // error or let the stdout-EOF failure win the race; `shutdown()` remains the synchronous backstop. let process = self.process - let stdinPipe = self.stdinPipe + let stdin = self.stdin DispatchQueue.global(qos: .userInitiated).async { - RPCChildProcessTeardown.terminate(process: process, stdinPipe: stdinPipe) + RPCChildProcessTeardown.terminate(process: process, stdin: stdin) } } @@ -1328,9 +1328,13 @@ private final class CodexRPCClient: @unchecked Sendable { } private func sendPayload(_ payload: [String: Any]) throws { - let data = try JSONSerialization.data(withJSONObject: payload) - self.stdinPipe.fileHandleForWriting.write(data) - self.stdinPipe.fileHandleForWriting.write(Data([0x0A])) + var data = try JSONSerialization.data(withJSONObject: payload) + data.append(0x0A) + do { + try self.stdin.write(data) + } catch { + throw RPCWireError.requestFailed("codex app-server stdin closed: \(error.localizedDescription)") + } } private func readNextMessage() async throws -> [String: Any] { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 261b9acef..a4bbfc8f3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -3411,6 +3411,7 @@ enum CostUsageScanner { scheduledFiles: [URL], pendingPaths: Set, attemptedPaths: Set, + processedPaths: Set, cache: CostUsageCache) -> Set { Set(scheduledFiles.compactMap { fileURL -> String? in @@ -3421,6 +3422,9 @@ enum CostUsageScanner { if metadata.fileId == nil, !FileManager.default.fileExists(atPath: fileURL.path) { return resolvedPath } + if processedPaths.contains(fileURL.path), cache.files[fileURL.path] == nil { + return resolvedPath + } guard let usage = cache.files[fileURL.path], usage.codexScanComplete == true, !usage.hasBufferedCodexForkRetryLines, @@ -5248,11 +5252,16 @@ enum CostUsageScanner { return nil } + private enum CodexFileScanOutcome { + case processed + case deferred + } + private static func scanCodexFile( fileURL: URL, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) throws + state: inout CodexScanState) throws -> CodexFileScanOutcome { try context.checkCancellation?() let metadata = Self.codexFileMetadata(fileURL: fileURL) @@ -5263,7 +5272,7 @@ enum CostUsageScanner { } if let fileId = metadata.fileId, state.seenFileIds.contains(fileId) { Self.dropCachedCodexFile(path: metadata.path, cached: cache.files[metadata.path], cache: &cache) - return + return .processed } Self.reconcileCodexCachePathAliases( metadata: metadata, @@ -5274,7 +5283,7 @@ enum CostUsageScanner { let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached) if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { - return + return .processed } let pendingWorkBytes = Self.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) @@ -5293,7 +5302,7 @@ enum CostUsageScanner { "limit": "\(budget.maxBytesPerRefresh)", ]) // Preserve stale cache so later refreshes can resume catch-up. - return + return .deferred } } else { allowedWorkBytes = pendingWorkBytes @@ -5307,7 +5316,7 @@ enum CostUsageScanner { maxBytesToRead: allowedWorkBytes) { context.scanBudget?.consume(workBytes: allowedWorkBytes) - return + return .processed } let fullRescanWorkBytes = max(0, metadata.size) let fullRescanAllowedBytes: Int64 @@ -5321,7 +5330,7 @@ enum CostUsageScanner { case .deferBudget: // No work was consumed by the rejected incremental path, so this is only // reachable when the refresh budget has no allowance for the full rescan. - return + return .deferred } } else { fullRescanAllowedBytes = fullRescanWorkBytes @@ -5334,6 +5343,7 @@ enum CostUsageScanner { state: &state, maxBytesToRead: fullRescanAllowedBytes) context.scanBudget?.consume(workBytes: fullRescanAllowedBytes) + return .processed } static func pendingCodexScanWorkBytes(metadata: CodexFileMetadata, cached: CostUsageFileUsage?) -> Int64 { @@ -6276,6 +6286,11 @@ enum CostUsageScanner { filePathsInScan.formUnion(scanResult.scannedPaths.map { Self.codexPathKey(URL(fileURLWithPath: $0)) }) + let processedWithoutCachePathKeys = Set(scanResult.processedPaths.compactMap { path -> String? in + guard cache.files[path] == nil else { return nil } + return Self.codexPathKey(URL(fileURLWithPath: path)) + }) + filePathsInScan.subtract(processedWithoutCachePathKeys) let pendingLookbackPathCount = shouldBoundCatchUp ? boundedQueuePathCount : activeLookbackState.pendingFilePaths.count @@ -6284,6 +6299,7 @@ enum CostUsageScanner { scheduledFiles: filesScheduledForRefresh, pendingPaths: pendingLookbackPaths, attemptedPaths: scanResult.attemptedPaths, + processedPaths: scanResult.processedPaths, cache: cache) cache.codexActiveLookbackState = Self.finalizedCodexActiveLookbackState( activeLookbackState, @@ -6601,6 +6617,7 @@ enum CostUsageScanner { private struct CodexFileScanResult { let scannedPaths: Set let attemptedPaths: Set + let processedPaths: Set } private static func scanCodexFiles( @@ -6614,17 +6631,21 @@ enum CostUsageScanner { var visitedPaths = Set(files.map(\.standardizedFileURL.path)) var scannedPaths = Set(files.map(\.path)) var attemptedPaths: Set = [] + var processedPaths: Set = [] for fileURL in files { if context.scanBudget?.shouldStopBeforeNextFile() == true { break } context.workRecorder?.recordCodexFileScanAttempt(path: Self.codexPathKey(fileURL)) attemptedPaths.insert(fileURL.path) - try Self.scanCodexFile( + let outcome = try Self.scanCodexFile( fileURL: fileURL, context: context, cache: &cache, state: &scanState) + if case .processed = outcome { + processedPaths.insert(fileURL.path) + } let usage = cache.files[fileURL.path] inheritedResolver.updateCachedUsage(fileURL: fileURL, usage: usage) if Self.shouldRetryBufferedCodexFork(usage) { @@ -6648,11 +6669,14 @@ enum CostUsageScanner { context.workRecorder?.recordCodexFileScanAttempt(path: Self.codexPathKey(fileURL)) scannedPaths.insert(fileURL.path) attemptedPaths.insert(fileURL.path) - try Self.scanCodexFile( + let outcome = try Self.scanCodexFile( fileURL: fileURL, context: context, cache: &cache, state: &dependencyState) + if case .processed = outcome { + processedPaths.insert(fileURL.path) + } let usage = cache.files[fileURL.path] inheritedResolver.updateCachedUsage(fileURL: fileURL, usage: usage) if Self.shouldRetryBufferedCodexFork(usage) { @@ -6668,16 +6692,22 @@ enum CostUsageScanner { var retriedPaths: Set = [] for fileURL in bufferedForkRetries where retriedPaths.insert(fileURL.path).inserted { guard Self.shouldRetryBufferedCodexFork(cache.files[fileURL.path]) else { continue } - try Self.scanCodexFile( + let outcome = try Self.scanCodexFile( fileURL: fileURL, context: context, cache: &cache, state: &retryState) + if case .processed = outcome { + processedPaths.insert(fileURL.path) + } inheritedResolver.updateCachedUsage( fileURL: fileURL, usage: cache.files[fileURL.path]) } - return CodexFileScanResult(scannedPaths: scannedPaths, attemptedPaths: attemptedPaths) + return CodexFileScanResult( + scannedPaths: scannedPaths, + attemptedPaths: attemptedPaths, + processedPaths: processedPaths) } private static func shouldRetryBufferedCodexFork(_ usage: CostUsageFileUsage?) -> Bool { diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 626322f3d..bfab8653e 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -1325,3 +1325,34 @@ final class AlibabaTokenPlanStubURLProtocol: URLProtocol { override func stopLoading() {} } + +struct AlibabaTokenPlanSECTokenScrapeTests { + @Test + func `extracts the OneConsole SEC_TOKEN embedded in the dashboard shell`() { + // The aliyun OneConsole shell embeds the token as an upper-case, unquoted key inside + // `window.ALIYUN_CONSOLE_CONFIG` — the shape the mainland Personal/Solo gateway requires. + let html = """ + + """ + #expect(AlibabaTokenPlanUsageFetcher.extractSECToken(from: html) == "NwsiCAv9SDsHsNab4Jexample") + } + + @Test + func `still extracts the lower-case secToken and sec_token shapes`() { + #expect( + AlibabaTokenPlanUsageFetcher.extractSECToken(from: #"{"secToken":"abc123"}"#) == "abc123") + #expect( + AlibabaTokenPlanUsageFetcher.extractSECToken(from: #"var x = { sec_token: 'def456' };"#) == "def456") + } + + @Test + func `returns nil when no token is present`() { + #expect(AlibabaTokenPlanUsageFetcher.extractSECToken(from: "no token here") == nil) + } +} diff --git a/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift b/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift index 891aa7ffa..7687d2ebf 100644 --- a/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift +++ b/Tests/CodexBarTests/CLICardsClaudeSwapTests.swift @@ -271,7 +271,7 @@ struct CLICardsClaudeSwapTests { "Re-login required. Re-authenticate this account in claude-swap.", "claude-swap could not read the active account's Keychain entry.", "No stored credentials for this account slot.", - "Usage fetch failed.", + "Polling deferred until a limit resets.", "Unrecognized claude-swap status: future_status", "No usage windows reported.", ]) @@ -279,7 +279,7 @@ struct CLICardsClaudeSwapTests { @Test func `active sentinel account remains active and metrics less in full and brief cards`() async { - let problem = "Usage fetch failed." + let problem = "Polling deferred until a limit resets." let output = await CLIClaudeSwapCards.fetch( eligible: true, executablePath: "/fake/cswap", @@ -315,6 +315,83 @@ struct CLICardsClaudeSwapTests { #expect(rows.first?.usedPercent == nil) } + @Test + func `unavailable at limit windows keep metrics and name the exhausted window`() async { + let reset = Date(timeIntervalSince1970: 1_700_003_600) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)), + self.row(number: 2), + ]) + }) + + #expect(output.exitCode == .success) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "limited@example.com") + #expect(activeCard?.isActive == true) + #expect(activeCard?.accountProblem == + "Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1h.") + #expect(activeCard?.metrics.isEmpty == false) + #expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true) + #expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true) + + let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? []) + #expect(rows.first?.accountProblem?.contains("Session limit reached") == true) + #expect(rows.first?.usedPercent == 100) + } + + @Test + func `unavailable null usage retains previous CLI windows`() async { + let reset = Date(timeIntervalSince1970: 1_700_003_600) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: reset)), + ]), + now: Date(timeIntervalSince1970: 1_700_000_000)) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + self.row( + number: 1, + active: true, + status: .unavailable, + email: "limited@example.com", + hasUsage: false), + self.row(number: 2), + ]) + }, + previousAccounts: previous) + + #expect(output.exitCode == .success) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "limited@example.com") + #expect(activeCard?.metrics.isEmpty == false) + #expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true) + #expect(activeCard?.accountProblem?.contains("Session limit reached") == true) + #expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true) + } + @Test func `blank executable path preserves ambient output and fails distinctly`() async { let ambient = self.ambientOutput() diff --git a/Tests/CodexBarTests/CLIServeWebUITests.swift b/Tests/CodexBarTests/CLIServeWebUITests.swift index 9514332f5..76503ab87 100644 --- a/Tests/CodexBarTests/CLIServeWebUITests.swift +++ b/Tests/CodexBarTests/CLIServeWebUITests.swift @@ -30,6 +30,28 @@ struct CLIServeWebUITests { #expect(CLIServeWebUI.iconResponse(name: "../etc/passwd") == nil) } + @Test + func `web ui renders account windows alongside an error note`() { + let html = self.html + let errorAppend = "card.append(node(\"p\", \"error-message\", account.error));" + #expect(html.contains(errorAppend)) + #expect(!html.contains(errorAppend + "\n return card;")) + #expect(html.contains( + "for (const window of visibleWindows(account.windows)) windows.append(renderWindow(window))")) + } + + @Test + func `web ui skips windows the snapshot marks idle`() { + let html = self.html + // The producer decides which lanes are idle, so the page must not repeat any + // provider-specific rule. It filters on the generic flag and nothing else. + #expect(html.contains("function visibleWindows(windows)")) + #expect(html.contains("w.idle !== true")) + #expect(html.contains("for (const window of visibleWindows(provider.windows))")) + #expect(html.contains("for (const window of visibleWindows(account.windows))")) + #expect(html.contains("worstWindowLevel(visibleWindows(account.windows))")) + } + @Test func `web ui keeps ambient windows when no accounts are present`() { let html = self.html @@ -37,6 +59,15 @@ struct CLIServeWebUITests { #expect(html.contains("renderWindow(window)")) } + @Test + func `web ui keeps healthy ambient summary when active swap account has no usage`() { + let html = self.html + #expect(html.contains("const activeAccount = accounts.find(account => account.active === true)")) + #expect(html.contains("visibleWindows(activeAccount.windows).length > 0")) + #expect(html.contains("provider.cost || provider.credits || provider.status")) + #expect(html.contains("if (!activeHasUsableWindows && hasAmbientSummary) rest.push(provider)")) + } + @Test func `web ui renders daily spend charts from cost history`() { let html = self.html diff --git a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift index 191d27234..f400d8c7b 100644 --- a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift +++ b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift @@ -208,6 +208,50 @@ struct ClaudeProviderRuntimeTests { #expect(store.claudeSwapTransientState.lastErrorAccountID == nil) } + @Test + func `unavailable refresh retains previous at limit snapshot`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeUnavailableListExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let now = Date() + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: now.addingTimeInterval(3600)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: now.addingTimeInterval(86400))), + ]), + now: now) + store.claudeSwapAccountSnapshots = previous + + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refreshClaudeSwapAccounts() + } + + let account = try #require(store.claudeSwapAccountSnapshots.first) + #expect(account.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "1")) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.secondary?.usedPercent == 100) + #expect(account.snapshot?.updatedAt == now) + let error = try #require(account.error) + #expect(error.contains("Session limit reached")) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Usage fetch failed")) + #expect(store.claudeSwapLastError == nil) + } + private func makeStore() -> (SettingsStore, UsageStore) { let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -273,6 +317,28 @@ struct ClaudeProviderRuntimeTests { return url.path } + private func makeUnavailableListExecutable() throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-unavailable-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + if [ "$1" = "--version" ]; then + echo 'cswap 0.22.0' + exit 0 + fi + cat <<'EOF' + {"schemaVersion":1,"activeAccountNumber":1,"accounts":[ + {"number":1,"email":"a@b.c","active":true,"usageStatus":"unavailable","usage":null} + ]} + EOF + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + private func makeFailedSwitchExecutable() throws -> String { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("claude-failed-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/ClaudeSwapAccountAliasProjectionTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountAliasProjectionTests.swift new file mode 100644 index 000000000..7a55690ab --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapAccountAliasProjectionTests.swift @@ -0,0 +1,247 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSwapAccountAliasProjectionTests { + private let now = Date(timeIntervalSince1970: 1_782_000_000) + + @Test + func `keeps unique emails as email only even when organization names are present`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "work@example.com", organizationName: "Sendbird"), + self.row(number: 2, email: "personal@example.com", organizationName: "Acme", active: true)), + now: self.now) + + #expect(snapshots.map(\.displayLabel) == ["personal@example.com", "work@example.com"]) + #expect(snapshots.map { $0.snapshot?.identity?.accountOrganization } == [nil, nil]) + #expect(snapshots.map { $0.snapshot?.identity?.accountEmail } == [ + "personal@example.com", + "work@example.com", + ]) + #expect(snapshots.map(\.id.opaqueID) == ["2", "1"]) + } + + @Test + func `disambiguates shared emails with organization name or slot ordinal`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "shared@example.com", organizationName: "Sendbird"), + self.row(number: 4, email: "shared@example.com", organizationName: "", active: true)), + now: self.now) + + #expect(snapshots.map(\.displayLabel) == [ + "shared@example.com · Account 4", + "shared@example.com · Sendbird", + ]) + #expect(snapshots.first?.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "4")) + #expect(snapshots.last?.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "1")) + #expect(snapshots.first?.snapshot?.identity?.accountOrganization == nil) + #expect(snapshots.last?.snapshot?.identity?.accountOrganization == nil) + #expect(snapshots.first?.snapshot?.identity?.accountEmail == "shared@example.com") + } + + @Test + func `disambiguates shared emails that differ only by case`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "Shared@example.com", organizationName: "Sendbird"), + self.row(number: 2, email: "shared@example.com", organizationName: "Acme", active: true)), + now: self.now) + + #expect(snapshots.map(\.displayLabel) == [ + "shared@example.com · Acme", + "Shared@example.com · Sendbird", + ]) + #expect(snapshots.map { $0.snapshot?.identity?.accountEmail } == [ + "shared@example.com", + "Shared@example.com", + ]) + } + + @Test + func `appends account ordinal when shared emails also share an organization name`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "shared@example.com", organizationName: "Sendbird"), + self.row(number: 4, email: "shared@example.com", organizationName: "Sendbird", active: true)), + now: self.now) + + #expect(snapshots.map(\.displayLabel) == [ + "shared@example.com · Sendbird · Account 4", + "shared@example.com · Sendbird · Account 1", + ]) + #expect(snapshots.map(\.id.opaqueID) == ["4", "1"]) + #expect(snapshots.map { $0.snapshot?.identity?.accountEmail } == [ + "shared@example.com", + "shared@example.com", + ]) + #expect(snapshots.map { $0.snapshot?.identity?.accountOrganization } == [nil, nil]) + } + + @Test + func `appends account ordinal when same-mailbox emails differ only by case`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "Shared@example.com", organizationName: "Sendbird"), + self.row(number: 4, email: "shared@example.com", organizationName: "Sendbird", active: true)), + now: self.now) + + #expect(snapshots.map(\.displayLabel) == [ + "shared@example.com · Sendbird · Account 4", + "Shared@example.com · Sendbird · Account 1", + ]) + } + + @Test + func `prefers user alias over email and empty email ordinal`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "shared@example.com", organizationName: "Sendbird", alias: "Work"), + self.row(number: 2, email: "shared@example.com", organizationName: "Acme"), + self.row(number: 3, email: "", alias: "Empty slot")), + now: self.now) + + #expect(snapshots.map(\.displayLabel) == ["Work", "shared@example.com · Acme", "Empty slot"]) + #expect(snapshots.map(\.id.opaqueID) == ["1", "2", "3"]) + #expect(snapshots.map { $0.snapshot?.identity?.accountOrganization } == [nil, nil, nil]) + #expect(snapshots.map { $0.snapshot?.identity?.accountEmail } == [ + "shared@example.com", + "shared@example.com", + nil, + ]) + } + + @Test + func `disambiguates duplicate aliases including case only matches`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "one@example.com", alias: "Work"), + self.row(number: 2, email: "two@example.com", alias: "work", active: true)), + now: self.now) + + #expect(snapshots.map(\.displayLabel) == [ + "work · Account 2", + "Work · Account 1", + ]) + #expect(snapshots.map(\.id.opaqueID) == ["2", "1"]) + } + + @Test + func `cloud sync payload omits display only organization names`() throws { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "shared@example.com", organizationName: "Sendbird"), + self.row( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + alias: "Work", + active: true)), + now: self.now) + let account = try #require(snapshots.first) + let usage = try #require(account.snapshot) + let payload = AccountSnapshotSyncPayload( + provider: account.provider.instanceID, + deviceID: "test-device", + accountIdentity: account.accountEmail, + displayLabel: account.accountEmail ?? "Account \(account.id.opaqueID)", + usage: usage) + let data = try JSONEncoder().encode(payload) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let encodedUsage = try #require(json["usage"] as? [String: Any]) + + #expect(account.displayLabel == "Work") + #expect(payload.displayLabel == "shared@example.com") + #expect(payload.usage.identity?.accountOrganization == nil) + #expect(encodedUsage["accountOrganization"] == nil || encodedUsage["accountOrganization"] is NSNull) + #expect(encodedUsage["accountEmail"] as? String == "shared@example.com") + #expect(usage.identity?.accountID == "claude-swap:2") + } + + @Test + func `retained fingerprints skip email shaped aliases when the mailbox is empty`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "", alias: "owner@example.com", active: true)), + now: self.now) + let account = snapshots[0] + #expect(account.displayLabel == "owner@example.com") + #expect(account.accountEmail == nil) + #expect(ClaudeSwapRetainedUsageStore.fingerprint(from: account) == nil) + #expect(ClaudeSwapRetainedUsageStore.snapshotsForRetention(snapshots).isEmpty) + } + + @Test + func `cloud sync keys duplicate swap slots by source identity`() { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list( + self.row(number: 1, email: "shared@example.com", organizationName: "Sendbird"), + self.row( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: true)), + now: self.now) + let names = snapshots.compactMap { account -> String? in + guard let usage = account.snapshot else { return nil } + let identity = usage.identity?.accountID + ?? usage.identity?.accountEmail + ?? "\(account.id.source):\(account.id.opaqueID)" + return AccountSnapshotSyncPayload( + provider: account.provider.instanceID, + deviceID: "test-device", + accountIdentity: identity, + displayLabel: account.accountEmail ?? "Account \(account.id.opaqueID)", + usage: usage).recordName + } + + #expect(snapshots.map { $0.snapshot?.identity?.accountID } == [ + "claude-swap:2", + "claude-swap:1", + ]) + #expect(Set(names).count == 2) + } + + @Test + func `cloud sync payload omits aliases when swap email is missing`() throws { + let snapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: self.list(self.row(number: 3, email: "", alias: "Empty slot")), + now: self.now) + let account = try #require(snapshots.first) + let usage = try #require(account.snapshot) + let payload = AccountSnapshotSyncPayload( + provider: account.provider.instanceID, + deviceID: "test-device", + accountIdentity: "\(account.id.source):\(account.id.opaqueID)", + displayLabel: account.accountEmail ?? "Account \(account.id.opaqueID)", + usage: usage) + #expect(account.displayLabel == "Empty slot") + #expect(account.accountEmail == nil) + #expect(payload.displayLabel == "Account 3") + } + + private func list(_ rows: ClaudeSwapAccountRow...) -> ClaudeSwapAccountList { + ClaudeSwapAccountList( + activeAccountNumber: rows.first { $0.isActive }?.number, + accounts: rows) + } + + private func row( + number: Int, + email: String, + organizationName: String = "", + alias: String? = nil, + active: Bool = false) -> ClaudeSwapAccountRow + { + ClaudeSwapAccountRow( + number: number, + email: email, + organizationName: organizationName, + alias: alias, + isActive: active, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 10, resetsAt: nil), + sevenDay: nil) + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift index 567a069ec..15a651de4 100644 --- a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift +++ b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift @@ -61,6 +61,8 @@ struct ClaudeSwapAccountProjectionTests { #expect(active.snapshot?.secondary == nil) #expect(active.snapshot?.updatedAt == self.now) #expect(active.snapshot?.identity?.accountEmail == "personal@example.com") + #expect(active.snapshot?.identity?.accountOrganization == nil) + #expect(active.snapshot?.identity?.accountID == "claude-swap:2") #expect(active.snapshot?.identity?.loginMethod == "claude-swap") let inactive = try #require(snapshots.last) @@ -80,7 +82,6 @@ struct ClaudeSwapAccountProjectionTests { (.apiKey, "API-key account"), (.keychainUnavailable, "Keychain"), (.noCredentials, "No stored credentials"), - (.unavailable, "Usage fetch failed"), (.unknown("mystery"), "mystery"), ] @@ -101,11 +102,448 @@ struct ClaudeSwapAccountProjectionTests { #expect(snapshot.snapshot == nil) let error = try #require(snapshot.error) #expect(error.contains(entry.1)) - let expectedCanActivate = entry.0 == .apiKey || entry.0 == .unavailable - #expect(snapshot.canActivate == expectedCanActivate) + #expect(snapshot.canActivate == (entry.0 == .apiKey)) } } + @Test + func `unavailable without windows or prior snapshot reports deferred polling`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: nil, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: false, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.snapshot == nil) + #expect(snapshot.error == "Polling deferred until a limit resets.") + #expect(snapshot.canActivate == true) + #expect(snapshot.error?.contains("Usage fetch failed") != true) + } + + @Test + func `projects usage windows even when status is unavailable`() throws { + let reset = Date(timeIntervalSince1970: 1_782_003_600) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 42, resetsAt: nil)), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + let snapshot = try #require(account.snapshot) + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.secondary == nil) + #expect(account.error == "Session limit reached. Resets in 1h.") + #expect(account.error?.contains("Usage fetch failed") != true) + } + + @Test + func `unavailable attached windows drop expired lanes and keep remaining at limit`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-60)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + #expect(!error.contains("Resets now")) + } + + @Test + func `names each exhausted window including scoped models`() throws { + let sessionReset = Date(timeIntervalSince1970: 1_782_003_600) + let weeklyReset = Date(timeIntervalSince1970: 1_782_259_200) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: sessionReset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: weeklyReset), + scoped: [ + ClaudeSwapScopedUsageWindow(name: "Fable", usedPercent: 100, resetsAt: weeklyReset), + ]), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.secondary?.usedPercent == 100) + #expect(account.snapshot?.extraRateWindows?.first?.window.usedPercent == 100) + #expect(account.error == [ + "Session limit reached. Resets in 1h.", + "Weekly limit reached. Resets in 3d.", + "Fable limit reached. Resets in 3d.", + ].joined(separator: " ")) + } + + @Test + func `unavailable without windows retains previous snapshot as current at limit usage`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previousList = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)), + ]) + let previous = ClaudeSwapAccountProjection.accountSnapshots(from: previousList, now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now.addingTimeInterval(3600)).first) + #expect(account.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "1")) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.secondary?.usedPercent == 100) + #expect(account.snapshot?.updatedAt == self.now) + let error = try #require(account.error) + #expect(error.contains("Session limit reached")) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Usage fetch failed")) + #expect(!error.contains("last successful update")) + } + + @Test + func `unavailable retain drops expired windows and keeps remaining at limit lanes`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-60)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + #expect(!error.contains("Resets now")) + } + + @Test + func `unavailable retain drops a snapshot whose at limit windows have all reset`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-3600)), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(-60))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable retain drops exhausted windows without a reset timestamp`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + } + + @Test + func `unavailable retain drops unknown reset lanes that are not exhausted`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: nil), + sevenDay: ClaudeSwapUsageWindow( + usedPercent: 100, + resetsAt: self.now.addingTimeInterval(86400))), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot?.primary == nil) + #expect(account.snapshot?.secondary?.usedPercent == 100) + let error = try #require(account.error) + #expect(error.contains("Weekly limit reached")) + #expect(!error.contains("Session limit reached")) + } + + @Test + func `token expired does not retain a previous usage snapshot`() throws { + let previousList = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil), + sevenDay: nil), + ]) + let previous = ClaudeSwapAccountProjection.accountSnapshots(from: previousList, now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .tokenExpired, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error?.contains("Token expired") == true) + } + + @Test + func `token expired with cached windows stays metrics less`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .tokenExpired, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 80, resetsAt: nil)), + ]) + + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error?.contains("Token expired") == true) + } + + @Test + func `unavailable does not reuse a previous snapshot from a different email`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "new@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.displayLabel == "new@example.com") + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable does not retain a previous snapshot that is not at a limit`() throws { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 20, resetsAt: nil), + sevenDay: nil), + ]), + now: self.now) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + @Test func `ok row without windows reports missing usage instead of an empty card`() throws { let list = ClaudeSwapAccountList( @@ -197,4 +635,195 @@ struct ClaudeSwapAccountProjectionTests { let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) #expect(snapshot.displayLabel == "Account 3") } + + @Test + func `unavailable retain ignores cached windows after the slot account changes`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "new@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: cached, + now: self.now).first) + #expect(account.displayLabel == "new@example.com") + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable retain ignores cached windows when the slot has no email`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous) + #expect(cached.isEmpty) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: previous, + now: self.now).first) + #expect(account.displayLabel == "Account 1") + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `unavailable retain keeps cached windows for the same slot account`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "same@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "same@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: cached, + now: self.now).first) + #expect(account.snapshot?.primary?.usedPercent == 100) + #expect(account.snapshot?.identity?.accountEmail == "same@example.com") + #expect(account.error?.contains("Session limit reached") == true) + } + + @Test + func `unavailable retain ignores a cache entry with no account discriminator`() throws { + let reset = Date(timeIntervalSince1970: 1_782_259_200) + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: nil), + ]), + now: self.now) + let stripped = previous.map { account in + ProviderAccountUsageSnapshot( + id: account.id, + provider: account.provider, + displayLabel: "", + isActive: account.isActive, + snapshot: account.snapshot.map { snapshot in + UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + extraRateWindows: snapshot.extraRateWindows, + updatedAt: snapshot.updatedAt, + identity: nil) + }, + error: nil, + sourceLabel: account.sourceLabel) + } + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "old@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: nil, + sevenDay: nil), + ]) + + let account = try #require( + ClaudeSwapAccountProjection.accountSnapshots( + from: list, + previousAccounts: stripped, + now: self.now).first) + #expect(account.snapshot == nil) + #expect(account.error == "Polling deferred until a limit resets.") + } + + @Test + func `previous accounts prefer in-memory snapshots over an empty cache load`() { + let previous = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "work@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: nil), + sevenDay: nil), + ]), + now: self.now) + #expect(ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: previous).count == previous.count) + #expect(ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: []).isEmpty) + } } diff --git a/Tests/CodexBarTests/ClaudeSwapListParserTests.swift b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift index 69fe61f35..340ce373b 100644 --- a/Tests/CodexBarTests/ClaudeSwapListParserTests.swift +++ b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift @@ -50,6 +50,8 @@ struct ClaudeSwapListParserTests { let first = try #require(list.accounts.first) #expect(first.number == 1) #expect(first.email == "work@example.com") + #expect(first.organizationName.isEmpty) + #expect(first.alias == nil) #expect(first.isActive == false) #expect(first.usageStatus == .ok) #expect(first.fiveHour?.usedPercent == 25.0) @@ -64,6 +66,8 @@ struct ClaudeSwapListParserTests { let second = try #require(list.accounts.last) #expect(second.isActive == true) + #expect(second.organizationName.isEmpty) + #expect(second.alias == nil) #expect(second.fiveHour?.usedPercent == 80) #expect(second.fiveHour?.resetsAt == nil) #expect(second.sevenDay == nil) @@ -104,6 +108,46 @@ struct ClaudeSwapListParserTests { #expect(row.scoped.first?.resetsAt == Date(timeIntervalSince1970: 1_784_620_800)) } + @Test + func `decodes display only organization name and optional alias`() throws { + let json = """ + { + "schemaVersion": 1, + "activeAccountNumber": 1, + "accounts": [ + { + "number": 1, + "email": "shared@example.com", + "organizationName": "Sendbird", + "alias": "Work", + "organizationUuid": "ignored-uuid", + "isOrganization": true, + "active": true, + "usageStatus": "ok", + "usage": null + }, + { + "number": 2, + "email": "shared@example.com", + "organizationName": " ", + "alias": "", + "active": false, + "usageStatus": "ok", + "usage": null + } + ] + } + """ + + let accounts = try self.parse(json).accounts + let first = try #require(accounts.first) + #expect(first.organizationName == "Sendbird") + #expect(first.alias == "Work") + let second = try #require(accounts.last) + #expect(second.organizationName.isEmpty) + #expect(second.alias == nil) + } + @Test func `parses empty account list without accounts configured`() throws { let json = """ diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift index ccbc9c4d6..610a7b121 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift @@ -339,6 +339,7 @@ struct TestCodexFetchStrategy: ProviderFetchStrategy { var id = "test-codex" var kind: ProviderFetchKind = .cli var sourceLabel = "test-codex" + var codexPATCredentialOwner: CodexPATCredentialOwner? func isAvailable(_: ProviderFetchContext) async -> Bool { true @@ -346,10 +347,18 @@ struct TestCodexFetchStrategy: ProviderFetchStrategy { func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { let snapshot = try await self.loader() - return self.makeResult( + let result = self.makeResult( usage: snapshot, credits: self.credits, sourceLabel: self.sourceLabel) + return ProviderFetchResult( + usage: result.usage, + credits: result.credits, + dashboard: result.dashboard, + sourceLabel: result.sourceLabel, + strategyID: result.strategyID, + strategyKind: result.strategyKind, + codexPATCredentialOwner: self.codexPATCredentialOwner) } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift index 9b1350d30..70b6a644b 100644 --- a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift @@ -141,21 +141,22 @@ struct CodexBaselineCharacterizationTests { } @Test - func `app auto pipeline order is OAuth then CLI without web`() async { + func `app auto pipeline order is PAT then OAuth then CLI without web`() async { let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto) - #expect(strategyIDs == ["codex.oauth", "codex.cli"]) + #expect(strategyIDs == ["codex.pat", "codex.oauth", "codex.cli"]) } @Test - func `CLI auto pipeline order is OAuth then CLI without web`() async { + func `CLI auto pipeline order is PAT then OAuth then CLI without web`() async { let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: .auto) - #expect(strategyIDs == ["codex.oauth", "codex.cli"]) + #expect(strategyIDs == ["codex.pat", "codex.oauth", "codex.cli"]) } @Test func `explicit fetch plan modes keep Codex strategy selection`() async { let appCases: [(ProviderSourceMode, [String])] = [ (.oauth, ["codex.oauth", "codex.oauth-native-refresh-cli"]), + (.api, ["codex.pat"]), (.cli, ["codex.cli"]), (.web, ["codex.web.dashboard"]), ] @@ -187,8 +188,8 @@ struct CodexBaselineCharacterizationTests { env: env, codexArguments: stubCLI.arguments) - #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth", "codex.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + #expect(outcome.attempts.map(\.strategyID) == ["codex.pat", "codex.oauth", "codex.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) switch outcome.result { case let .success(result): @@ -217,9 +218,9 @@ struct CodexBaselineCharacterizationTests { env: env, codexArguments: stubCLI.arguments) - #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true]) - #expect(outcome.attempts[0].errorDescription?.isEmpty == false) + #expect(outcome.attempts.map(\.strategyID) == ["codex.pat", "codex.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + #expect(outcome.attempts[1].errorDescription?.isEmpty == false) switch outcome.result { case .success: @@ -345,8 +346,8 @@ struct CodexBaselineCharacterizationTests { settings: settings, codexArguments: stubCLI.arguments) - #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth", "codex.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + #expect(outcome.attempts.map(\.strategyID) == ["codex.pat", "codex.oauth", "codex.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) switch outcome.result { case let .success(result): @@ -377,8 +378,8 @@ struct CodexBaselineCharacterizationTests { ], settings: settings) - #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true]) + #expect(outcome.attempts.map(\.strategyID) == ["codex.pat", "codex.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) switch outcome.result { case .success: diff --git a/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift index 2b1b388c5..6b264f0b7 100644 --- a/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift +++ b/Tests/CodexBarTests/CodexOAuthManagedWorkspaceRecoveryTests.swift @@ -8,7 +8,7 @@ struct CodexOAuthManagedWorkspaceRecoveryTests { let context = self.makeContext(sourceMode: .auto) let strategies = await CodexProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies(context) - #expect(strategies.map(\.id) == ["codex.oauth"]) + #expect(strategies.map(\.id) == ["codex.pat", "codex.oauth"]) } @Test diff --git a/Tests/CodexBarTests/CodexPATAppPublicationTests.swift b/Tests/CodexBarTests/CodexPATAppPublicationTests.swift new file mode 100644 index 000000000..067689751 --- /dev/null +++ b/Tests/CodexBarTests/CodexPATAppPublicationTests.swift @@ -0,0 +1,144 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension CodexAccountScopedRefreshTests { + @Test + func `ambient PAT publishes while a different managed Codex account is active`() async throws { + let settings = self.makeSettingsStore(suite: "CodexPATAppPublicationTests-managed-active") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + settings.codexUsageDataSource = .auto + settings._test_liveSystemCodexAccount = self.liveAccount(email: "live-oauth@example.com") + + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-999999999999")) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pat-managed-\(UUID().uuidString)", isDirectory: true) + let managedAccount = ManagedCodexAccount( + id: managedID, + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + settings._test_managedCodexAccountStoreURL = managedStoreURL + settings.codexActiveSource = .managedAccount(id: managedID) + + let ambientRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pat-ambient-\(UUID().uuidString)", isDirectory: true) + let ambientCodexHome = ambientRoot.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory( + at: ambientCodexHome, withIntermediateDirectories: true) + try Data(#"{"personal_access_token":"at-test-token"}"#.utf8) + .write(to: ambientCodexHome.appendingPathComponent("auth.json")) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: managedStoreURL) + try? FileManager.default.removeItem(at: managedHome) + try? FileManager.default.removeItem(at: ambientRoot) + } + + let environment = [ + "HOME": ambientRoot.path, + "CODEX_HOME": ambientCodexHome.path, + "XDG_CONFIG_HOME": ambientRoot.appendingPathComponent(".config", isDirectory: true) + .path, + ] + let store = self.makeUsageStore(settings: settings, environmentBase: environment) + store._test_codexResetCreditsFetcherOverride = { _ in nil } + let baseSpec = try #require(store.providerSpecs[.codex]) + let patSnapshot = self.codexSnapshot(email: "pat@example.com", usedPercent: 68) + let strategy = TestCodexFetchStrategy( + loader: { patSnapshot }, + credits: nil, + id: "codex.pat", + kind: .apiToken, + sourceLabel: "pat", + codexPATCredentialOwner: .ambientCodexHome) + store.providerSpecs[.codex] = Self.makeCodexProviderSpec(baseSpec: baseSpec) { _ in + [strategy] + } + + #expect(store.shouldUseAmbientCodexPATForUsage()) + #expect(!store.shouldFetchAllCodexVisibleAccounts()) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "pat@example.com") + #expect(store.snapshots[.codex]?.primary?.usedPercent == 68) + #expect(store.lastSourceLabels[.codex] == "pat") + #expect(store.lastCodexUsagePublicationGuard?.source == .liveSystem) + #expect(store.lastCodexUsagePublicationGuard?.accountKey == "pat@example.com") + #expect(store.lastCodexUsagePublicationGuard?.identity == .emailOnly(normalizedEmail: "pat@example.com")) + } + + @Test + func `profile-only PAT skips stacked fan-out and publishes`() async throws { + let settings = self.makeSettingsStore(suite: "CodexPATAppPublicationTests-profile-pat") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + settings.codexUsageDataSource = .auto + settings._test_liveSystemCodexAccount = self.liveAccount(email: "live-oauth@example.com") + + let profileHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pat-profile-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: profileHome, withIntermediateDirectories: true) + try Data(#"{"personal_access_token":"at-profile-only"}"#.utf8) + .write(to: profileHome.appendingPathComponent("auth.json")) + settings.updateProviderConfig(provider: .codex) { entry in + entry.codexProfileHomePaths = [profileHome.path] + entry.codexActiveSource = .profileHome(path: profileHome.path) + } + + let ambientRoot = FileManager.default.temporaryDirectory + .appendingPathComponent( + "codex-pat-ambient-empty-\(UUID().uuidString)", isDirectory: true) + let ambientCodexHome = ambientRoot.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory( + at: ambientCodexHome, withIntermediateDirectories: true) + defer { + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: profileHome) + try? FileManager.default.removeItem(at: ambientRoot) + } + + let environment = [ + "HOME": ambientRoot.path, + "CODEX_HOME": ambientCodexHome.path, + "XDG_CONFIG_HOME": ambientRoot.appendingPathComponent(".config", isDirectory: true) + .path, + ] + let store = self.makeUsageStore(settings: settings, environmentBase: environment) + store._test_codexResetCreditsFetcherOverride = { _ in nil } + let baseSpec = try #require(store.providerSpecs[.codex]) + let patSnapshot = self.codexSnapshot(email: "pat-profile@example.com", usedPercent: 41) + let strategy = TestCodexFetchStrategy( + loader: { patSnapshot }, + credits: nil, + id: "codex.pat", + kind: .apiToken, + sourceLabel: "pat", + codexPATCredentialOwner: .scopedCodexHome(path: profileHome.path)) + store.providerSpecs[.codex] = Self.makeCodexProviderSpec(baseSpec: baseSpec) { _ in + [strategy] + } + + #expect((try? CodexOAuthCredentialsStore.loadPAT(env: store.environmentBase)) == nil) + #expect(store.shouldUseAmbientCodexPATForUsage()) + #expect(!store.shouldFetchAllCodexVisibleAccounts()) + + await store.refreshProvider(.codex, allowDisabled: true) + + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "pat-profile@example.com") + #expect(store.snapshots[.codex]?.primary?.usedPercent == 41) + #expect(store.lastSourceLabels[.codex] == "pat") + #expect(store.lastCodexUsagePublicationGuard?.source == .profileHome(path: profileHome.path)) + #expect(store.lastCodexUsagePublicationGuard?.accountKey == "pat-profile@example.com") + #expect( + store.lastCodexUsagePublicationGuard?.identity == + .emailOnly(normalizedEmail: "pat-profile@example.com")) + } +} diff --git a/Tests/CodexBarTests/CodexPATTests.swift b/Tests/CodexBarTests/CodexPATTests.swift new file mode 100644 index 000000000..7d6553775 --- /dev/null +++ b/Tests/CodexBarTests/CodexPATTests.swift @@ -0,0 +1,357 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct CodexPATTests { + @Test + func `parses personal access token credentials`() throws { + let json = """ + { + "OPENAI_API_KEY": null, + "personal_access_token": "at-test-token" + } + """ + let credentials = try CodexOAuthCredentialsStore.parsePAT(data: Data(json.utf8)) + #expect(credentials.token == "at-test-token") + #expect(credentials.source == .codexHome) + } + + @Test + func `OAuth parse ignores a PAT-only auth file`() { + let json = """ + { + "OPENAI_API_KEY": null, + "personal_access_token": "at-test-token" + } + """ + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore.parse(data: Data(json.utf8)) + } + guard case .missingTokens = error else { + Issue.record("PAT-only auth.json must not be treated as OAuth credentials") + return + } + } + + @Test + func `PAT parse does not fall through to OAuth tokens`() throws { + let json = """ + { + "personal_access_token": "at-preferred", + "OPENAI_API_KEY": "sk-test", + "tokens": { + "access_token": "oauth-access", + "refresh_token": "oauth-refresh" + } + } + """ + let pat = try CodexOAuthCredentialsStore.parsePAT(data: Data(json.utf8)) + let oauth = try CodexOAuthCredentialsStore.parse(data: Data(json.utf8)) + #expect(pat.token == "at-preferred") + #expect(oauth.accessToken == "sk-test") + #expect(oauth.isAPIKey) + } + + @Test + func `blank personal access token is missing`() { + let json = """ + { + "personal_access_token": " " + } + """ + let error = #expect(throws: CodexOAuthCredentialsError.self) { + try CodexOAuthCredentialsStore.parsePAT(data: Data(json.utf8)) + } + guard case .missingTokens = error else { + Issue.record("Whitespace PAT must not parse as a credential") + return + } + } + + @Test + func `auto usage strategy prefers PAT over OAuth`() { + #expect( + CodexProviderDescriptor.resolveUsageStrategy( + selectedDataSource: .auto, + hasOAuthCredentials: true, + hasPATCredentials: true).dataSource == .pat) + #expect( + CodexProviderDescriptor.resolveUsageStrategy( + selectedDataSource: .auto, + hasOAuthCredentials: true, + hasPATCredentials: false).dataSource == .oauth) + #expect( + CodexProviderDescriptor.resolveUsageStrategy( + selectedDataSource: .pat, + hasOAuthCredentials: true, + hasPATCredentials: false).dataSource == .pat) + } + + @Test + func `User-Agent uses the Settings Codex version without a hardcoded fallback`() { + #expect( + CodexPATUsageFetcher._normalizedCLIVersionForTesting("codex-cli 0.148.0-alpha.9") + == "0.148.0-alpha.9") + #expect(CodexPATUsageFetcher._normalizedCLIVersionForTesting("1.2.3") == "1.2.3") + #expect(CodexPATUsageFetcher._normalizedCLIVersionForTesting(" ") == nil) + + let userAgent = CodexPATUsageFetcher._userAgentForTesting( + cliVersion: "codex-cli 0.148.0-alpha.9") + #expect(userAgent.hasPrefix("codex_cli_rs/0.148.0-alpha.9 (")) + #expect(userAgent.contains("; ")) + #expect(!userAgent.contains("codex-cli")) + } + + @Test + func `fetch context Settings version is the primary PAT User-Agent source`() { + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + resolvedCLIVersion: "codex-cli 9.9.9-settings") + #expect( + CodexPATFetchStrategy._resolvedCLIVersionForTesting(context: context) + == "codex-cli 9.9.9-settings") + } + + @Test + func `PAT whoami then usage send CLI User-Agent and skip OAuth extras`() async throws { + let transport = ProviderHTTPTransportStub { request in + let path = request.url?.path ?? "" + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer at-test-token") + #expect(request.value(forHTTPHeaderField: "originator") == "codex_cli_rs") + #expect( + request.value(forHTTPHeaderField: "User-Agent")?.hasPrefix("codex_cli_rs/1.2.3 (") == true) + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + if path.hasSuffix("/user-auth-credential/whoami") { + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == nil) + let response = try #require( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return ( + Data( + """ + { + "chatgpt_account_id": "acct-pat", + "chatgpt_plan_type": "team", + "email": "pat@example.com" + } + """.utf8), response) + } + + #expect(path.hasSuffix("/wham/usage") || path.hasSuffix("/api/codex/usage")) + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "acct-pat") + let response = try #require( + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return ( + Data( + """ + { + "plan_type": "team", + "rate_limit": { + "primary_window": { + "used_percent": 68, + "reset_at": 1766948068, + "limit_window_seconds": 604800 + }, + "secondary_window": null + } + } + """.utf8), response) + } + + let fetched = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) { + try await CodexPATUsageFetcher.fetchUsage( + credentials: CodexPATCredentials(token: "at-test-token"), + cliVersion: "1.2.3", + env: ["CODEX_HOME": "/tmp/codexbar-pat-usage-test"]) + } + + #expect(fetched.whoami?.accountId == "acct-pat") + #expect(fetched.whoami?.email == "pat@example.com") + #expect(fetched.whoami?.planType == "team") + #expect(fetched.usage.rateLimit?.primaryWindow?.usedPercent == 68) + let requests = await transport.requests() + #expect( + requests.map { $0.url?.path } == [ + "/api/accounts/v1/user-auth-credential/whoami", + "/backend-api/wham/usage", + ]) + } + + @Test + func `PAT usage mapping keeps pat source and does not request reset credits`() throws { + let json = """ + { + "plan_type": "team", + "rate_limit": { + "primary_window": { + "used_percent": 68, + "reset_at": 1766948068, + "limit_window_seconds": 604800 + }, + "secondary_window": null + } + } + """ + let result = try CodexPATFetchStrategy._mapResultForTesting( + Data(json.utf8), + whoami: CodexPATWhoami(accountId: "acct-pat", email: "pat@example.com", planType: "team")) + + #expect(result.sourceLabel == "pat") + #expect(result.strategyID == "codex.pat") + #expect(result.strategyKind == .apiToken) + #expect(result.codexResetCreditsAttempted) + #expect(result.usage.codexResetCredits == nil) + #expect(result.usage.primary == nil) + #expect(result.usage.secondary?.usedPercent == 68) + #expect(result.usage.accountEmail(for: .codex) == "pat@example.com") + #expect(result.usage.loginMethod(for: .codex) == "team") + } + + @Test + func `explicit PAT source does not include OAuth or CLI strategies`() async { + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: true, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + let strategies = await CodexProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies( + context) + #expect(strategies.map(\.id) == ["codex.pat"]) + #expect(strategies.map(\.kind) == [.apiToken]) + } + + @Test + func `PAT ignores managed and fail-closed CODEX_HOME when loading credentials`() throws { + let failClosed = "/Users/test/Library/Application Support/CodexBar/managed-store-unreadable" + let managedHome = + "/Users/test/Library/Application Support/CodexBar/managed-codex-homes/00000000-0000-0000-0000-000000000001" + let profileHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pat-profile-\(UUID().uuidString)", isDirectory: true) + let profileWithPAT = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pat-profile-with-pat-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: profileHome) + try? FileManager.default.removeItem(at: profileWithPAT) + } + try FileManager.default.createDirectory(at: profileHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileWithPAT, withIntermediateDirectories: true) + try Data(#"{"personal_access_token":"at-profile"}"#.utf8) + .write(to: profileWithPAT.appendingPathComponent("auth.json")) + + #expect( + CodexPATFetchStrategy._credentialEnvironmentForTesting([ + "CODEX_HOME": failClosed, "PATH": "/usr/bin", + ])[ + "CODEX_HOME", + ] == nil) + #expect( + CodexPATFetchStrategy._credentialEnvironmentForTesting(["CODEX_HOME": managedHome])[ + "CODEX_HOME", + ] == nil) + #expect( + CodexPATFetchStrategy._credentialEnvironmentForTesting(["CODEX_HOME": profileHome.path])[ + "CODEX_HOME", + ] == nil) + #expect( + CodexPATFetchStrategy._credentialEnvironmentForTesting(["CODEX_HOME": profileWithPAT.path])[ + "CODEX_HOME", + ] == profileWithPAT.path) + #expect( + CodexPATFetchStrategy._credentialOwnerForTesting(["CODEX_HOME": profileWithPAT.path]) == + .scopedCodexHome(path: profileWithPAT.standardizedFileURL.path)) + #expect( + CodexPATFetchStrategy._credentialOwnerForTesting(["CODEX_HOME": profileHome.path]) == + .ambientCodexHome) + #expect(CodexPATFetchStrategy._credentialEnvironmentForTesting([:])["CODEX_HOME"] == nil) + } + + @Test + func `PAT ambient fallback uses env HOME instead of the process user home`() throws { + let ambientRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pat-home-\(UUID().uuidString)", isDirectory: true) + let ambientCodexHome = ambientRoot.appendingPathComponent(".codex", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pat-managed-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: ambientRoot) + try? FileManager.default.removeItem(at: managedHome) + } + try FileManager.default.createDirectory(at: ambientCodexHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: managedHome, withIntermediateDirectories: true) + try Data(#"{"personal_access_token":"at-from-home"}"#.utf8) + .write(to: ambientCodexHome.appendingPathComponent("auth.json")) + + let env = [ + "HOME": ambientRoot.path, + "CODEX_HOME": managedHome.path, + ] + let resolved = CodexPATFetchStrategy._credentialEnvironmentForTesting(env) + #expect(resolved["CODEX_HOME"] == ambientCodexHome.standardizedFileURL.path) + #expect(try CodexOAuthCredentialsStore.loadPATResolvingScopedHome(env: env).token == "at-from-home") + } + + @Test + func `explicit PAT source does not fall back, Auto does after an unusable PAT`() { + let strategy = CodexPATFetchStrategy() + let browserDetection = BrowserDetection(cacheTTL: 0) + func context(sourceMode: ProviderSourceMode) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: true, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + let explicit = context(sourceMode: .api) + #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: explicit)) + #expect( + !strategy.shouldFallback(on: CodexOAuthCredentialsError.missingTokens, context: explicit)) + + let auto = context(sourceMode: .auto) + #expect(strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: auto)) + #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.notFound, context: auto)) + #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.missingTokens, context: auto)) + #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.invalidResponse, context: auto)) + #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.serverError(500, nil), context: auto)) + } +} diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 7a4e440f0..c2178eea1 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -97,10 +97,26 @@ struct ConfigValidationTests { @Test func `reports unsupported source`() { + var config = CodexBarConfig.makeDefault() + // Gemini has no CLI source; Codex now accepts `.api` for PAT. + config.setProviderConfig(ProviderConfig(id: .gemini, source: .cli)) + let issues = CodexBarConfigValidator.validate(config) + #expect(issues.contains(where: { + $0.provider == .gemini && $0.code == "unsupported_source" + })) + } + + @Test + func `allows Codex API source without config apiKey`() { var config = CodexBarConfig.makeDefault() config.setProviderConfig(ProviderConfig(id: .codex, source: .api)) let issues = CodexBarConfigValidator.validate(config) - #expect(issues.contains(where: { $0.code == "unsupported_source" })) + #expect(!issues.contains(where: { + $0.provider == .codex && $0.code == "unsupported_source" + })) + #expect(!issues.contains(where: { + $0.provider == .codex && $0.code == "api_key_missing" + })) } @Test diff --git a/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift b/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift index e8215def4..a5b7f3731 100644 --- a/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift +++ b/Tests/CodexBarTests/CostUsageCatchUpCompletionTests.swift @@ -4,6 +4,163 @@ import Testing @Suite(.serialized) struct CostUsageCatchUpCompletionTests { + @Test + func `touched old Codex file drains catch-up and permits exact proof`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let oldDay = try #require(Calendar.current.date(byAdding: .day, value: -10, to: day)) + let oldISO = env.isoString(for: oldDay) + let currentISO = env.isoString(for: day) + let oldURL = try env.writeCodexSessionFile( + day: oldDay, + filename: "rollout-old.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(oldISO)","payload":{"session_id":"resumed-session"}}"#, + #"{"type":"turn_context","timestamp":"\#(oldISO)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(oldISO)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":5},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + let currentURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-current.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(currentISO)","payload":{"session_id":"resumed-session"}}"#, + #"{"type":"turn_context","timestamp":"\#(currentISO)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(currentISO)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(60)], + ofItemAtPath: oldURL.path) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(120)], + ofItemAtPath: currentURL.path) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let proofRecorder = CostUsageScanner.CodexScanWorkRecorder() + options.codexScanWorkRecorderForTesting = proofRecorder + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(180), + options: options) + + let completedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(completedCache.files[oldURL.path] == nil) + #expect(completedCache.files[currentURL.path] != nil) + #expect(proofRecorder.snapshot().codexProgressAccountingVisits == 1) + #expect(completedCache.codexActiveLookbackState == nil) + #expect(completedCache.codexScanInventoryPaths == [currentURL.path]) + #expect(completedCache.codexScanCatchUpPending == false) + + let roots = CostUsageScanner.codexSessionsRoots(options: options) + .map { $0.resolvingSymlinksInPath().standardizedFileURL.path } + .sorted() + var damagedCache = completedCache + damagedCache.codexActiveLookbackState = try CostUsageCodexActiveLookbackState( + scanSinceKey: #require(damagedCache.scanSinceKey), + rootPaths: roots, + completedRootPaths: roots, + pendingFilePaths: [oldURL.path], + completedCurrentWindowRootPaths: roots, + completedCurrentWindowFlatRootPaths: roots) + damagedCache.codexScanInventoryPaths = nil + damagedCache.codexScanCatchUpPending = true + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: damagedCache) + + options.codexScanWorkRecorderForTesting = nil + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(181), + options: options) + let repairedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(repairedCache.codexActiveLookbackState == nil) + #expect(repairedCache.codexScanCatchUpPending == false) + } + + @Test + func `unprocessed pending Codex file remains queued`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso = env.isoString(for: day) + let cachedURL = try env.writeCodexSessionFile( + day: day, + filename: "a-cached.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"cached"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":10},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n") + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + options.preferNewestCodexSessionsFirst = false + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let pendingURL = try env.writeCodexSessionFile( + day: day, + filename: "b-pending.jsonl", + contents: [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"pending"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + ].joined(separator: "\n") + "\n") + let cachedHandle = try FileHandle(forWritingTo: cachedURL) + try cachedHandle.seekToEnd() + try cachedHandle.write(contentsOf: Data("\n".utf8)) + try cachedHandle.close() + + var pendingCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + let roots = CostUsageScanner.codexSessionsRoots(options: options) + .map { $0.resolvingSymlinksInPath().standardizedFileURL.path } + .sorted() + pendingCache.codexActiveLookbackState = try CostUsageCodexActiveLookbackState( + scanSinceKey: #require(pendingCache.scanSinceKey), + rootPaths: roots, + completedRootPaths: roots, + pendingFilePaths: [cachedURL.path, pendingURL.path], + completedCurrentWindowRootPaths: roots, + completedCurrentWindowFlatRootPaths: roots) + pendingCache.codexScanCatchUpPending = true + CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: pendingCache) + + options.maxCodexScanBytesPerRefresh = 1 + options.maxCodexScanDurationPerRefresh = 60 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + let deferredCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(deferredCache.files[pendingURL.path] == nil) + #expect(deferredCache.codexActiveLookbackState?.pendingFilePaths.contains(pendingURL.path) == true) + #expect(deferredCache.codexScanCatchUpPending == true) + } + @Test func `device identity restoration queues validation beyond the bounded slice`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/DashboardAntigravityWindowTests.swift b/Tests/CodexBarTests/DashboardAntigravityWindowTests.swift index a6796ffec..ba7c304d3 100644 --- a/Tests/CodexBarTests/DashboardAntigravityWindowTests.swift +++ b/Tests/CodexBarTests/DashboardAntigravityWindowTests.swift @@ -5,11 +5,17 @@ import Testing /// The dashboard snapshot renders one Antigravity lane per quota bucket. The primary and secondary /// representatives are copies of two of those buckets, kept only so the icon and menu bar have -/// standard slots to read, so the dashboard must not repeat them as rows of their own. +/// standard slots to read, so the dashboard must not repeat them as rows of their own. Every family +/// stays in the payload for script clients, and the lanes of a family that reports no usage carry +/// `idle` so a display client can drop the same rows the menu card and the widget hide. struct DashboardAntigravityWindowTests { @Test func `dashboard renders one Antigravity lane per quota bucket`() throws { - let windows = try self.antigravityWindows(geminiSessionPercent: 3.9, geminiWeeklyPercent: 8.1) + let windows = try self.antigravityWindows( + geminiSessionPercent: 3.9, + geminiWeeklyPercent: 8.1, + thirdPartySessionPercent: 1.5, + thirdPartyWeeklyPercent: 2.5) #expect(windows.map { $0["label"] as? String } == [ "Gemini 5-hour", @@ -17,7 +23,9 @@ struct DashboardAntigravityWindowTests { "Claude/GPT 5-hour", "Claude/GPT weekly", ]) - #expect(windows.map { $0["usedPercent"] as? Double } == [3.9, 8.1, 0, 0]) + #expect(windows.map { $0["usedPercent"] as? Double } == [3.9, 8.1, 1.5, 2.5]) + // The key is absent rather than false, so a payload with no idle window keeps its old shape. + #expect(windows.allSatisfy { $0["idle"] == nil }) } @Test @@ -29,6 +37,39 @@ struct DashboardAntigravityWindowTests { #expect(!labels.contains("Claude and GPT")) } + @Test + func `dashboard marks an Antigravity family that reports no usage as idle`() throws { + let windows = try self.antigravityWindows(geminiSessionPercent: 3.9, geminiWeeklyPercent: 8.1) + + // Every lane still ships, so a script client loses nothing. + #expect(windows.map { $0["label"] as? String } == [ + "Gemini 5-hour", + "Gemini weekly", + "Claude/GPT 5-hour", + "Claude/GPT weekly", + ]) + #expect(windows.map { $0["idle"] as? Bool } == [nil, nil, true, true]) + } + + @Test + func `dashboard marks no Antigravity family idle when none reports usage`() throws { + let windows = try self.antigravityWindows(geminiSessionPercent: 0, geminiWeeklyPercent: 0) + + #expect(windows.count == 4) + #expect(windows.allSatisfy { $0["idle"] == nil }) + } + + @Test + func `dashboard leaves an Antigravity family with unknown usage unmarked`() throws { + let windows = try self.antigravityWindows( + geminiSessionPercent: 3.9, + geminiWeeklyPercent: 8.1, + thirdPartyUsageKnown: false) + + #expect(windows.count == 4) + #expect(windows.allSatisfy { $0["idle"] == nil }) + } + @Test func `dashboard keeps standard lanes for an Antigravity snapshot without summary windows`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -50,7 +91,10 @@ struct DashboardAntigravityWindowTests { /// `secondary` set to the most-used lane of each family rather than to distinct windows. private func antigravityWindows( geminiSessionPercent: Double, - geminiWeeklyPercent: Double) throws -> [[String: Any]] + geminiWeeklyPercent: Double, + thirdPartySessionPercent: Double = 0, + thirdPartyWeeklyPercent: Double = 0, + thirdPartyUsageKnown: Bool = true) throws -> [[String: Any]] { let now = Date(timeIntervalSince1970: 1_700_000_000) let geminiSession = RateWindow( @@ -63,9 +107,13 @@ struct DashboardAntigravityWindowTests { windowMinutes: 7 * 24 * 60, resetsAt: now, resetDescription: nil) - let thirdPartySession = RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: now, resetDescription: nil) + let thirdPartySession = RateWindow( + usedPercent: thirdPartySessionPercent, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil) let thirdPartyWeekly = RateWindow( - usedPercent: 0, + usedPercent: thirdPartyWeeklyPercent, windowMinutes: 7 * 24 * 60, resetsAt: now, resetDescription: nil) @@ -84,12 +132,12 @@ struct DashboardAntigravityWindowTests { id: "antigravity-quota-summary-3p-5h", title: "Claude/GPT 5-hour", window: thirdPartySession, - usageKnown: true), + usageKnown: thirdPartyUsageKnown), NamedRateWindow( id: "antigravity-quota-summary-3p-weekly", title: "Claude/GPT weekly", window: thirdPartyWeekly, - usageKnown: true), + usageKnown: thirdPartyUsageKnown), ] let usage = UsageSnapshot( primary: geminiWeeklyPercent >= geminiSessionPercent ? geminiWeekly : geminiSession, diff --git a/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift b/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift index b95263054..e7b6689b8 100644 --- a/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift +++ b/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift @@ -14,7 +14,11 @@ struct DashboardClaudeSwapSnapshotTests { now: self.generatedAt) let providers = try self.providers( identityMode: .redacted, - claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + claudeSwap: DashboardClaudeSwapInput( + accounts: accounts, + adapterError: nil, + weeklyWorkDays: nil, + showSingleAccount: true)) let claude = try #require(providers.first { $0["id"] as? String == "claude" }) let rows = try #require(claude["accounts"] as? [[String: Any]]) @@ -55,6 +59,354 @@ struct DashboardClaudeSwapSnapshotTests { #expect(emails == ["personal@personal.example", "work@example.com", "third@example.net"]) } + @Test + func `shared emails stay distinct and alias replaces the email label`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Work", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .full, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == ["Work", "shared@example.com · Acme"]) + #expect(rows.compactMap { $0["id"] as? String } == ["claude-swap:1", "claude-swap:2"]) + + let redactedProviders = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let redactedClaude = try #require(redactedProviders.first { $0["id"] as? String == "claude" }) + let redactedRows = try #require(redactedClaude["accounts"] as? [[String: Any]]) + #expect(redactedRows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + } + + @Test + func `redacted dashboard mode rewrites email shaped aliases`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "other@example.com", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput( + accounts: accounts, + adapterError: nil, + weeklyWorkDays: nil, + showSingleAccount: true)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + #expect(rows.compactMap { ($0["identity"] as? [String: Any])?["accountEmail"] as? String } == [ + "redacted@example.com", + "redacted@example.com", + ]) + } + + @Test + func `redacted dashboard mode keeps non email alias text around an address`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Work (owner@example.com)", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + #expect(rows.compactMap { ($0["identity"] as? [String: Any])?["accountEmail"] as? String } == [ + "redacted@example.com", + "redacted@example.com", + ]) + } + + @Test + func `redacted dashboard mode keeps alias text around colon prefixed emails`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Work:owner@example.com", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + #expect(rows.compactMap { ($0["identity"] as? [String: Any])?["accountEmail"] as? String } == [ + "redacted@example.com", + "redacted@example.com", + ]) + } + + @Test + func `redacted dashboard mode rewrites emails inside organization suffixes`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "admin@company.com", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "redacted@example.com · Account 1", + "redacted@example.com · Account 2", + ]) + } + + @Test + func `redacted dashboard mode rewrites internal and domain literal addresses`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Work (owner@corp)", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "ops@[192.0.2.1]", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + } + + @Test + func `redacted dashboard mode rewrites apostrophe local parts`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Work (o'connor@example.com)", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + } + + @Test + func `redacted dashboard mode rewrites unicode local parts`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "用户@例子.公司", + organizationName: "Sendbird", + active: true), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput( + accounts: accounts, + adapterError: nil, + weeklyWorkDays: nil, + showSingleAccount: true)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == ["redacted@例子.公司"]) + } + + @Test + func `redacted dashboard mode rewrites quoted local parts`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Contact \"owner\"@example.com", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + } + + @Test + func `redacted dashboard mode rewrites quoted local parts that contain spaces`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Contact \"owner smith\"@example.com", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + } + + @Test + func `redacted dashboard mode rewrites slash separated addresses independently`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.accountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "owner@example.com/backup@example.net", + active: true), + self.accountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + active: false), + ]), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == [ + "Account 1", + "redacted@example.com · Account 2", + ]) + } + @Test func `dashboard identity flag decodes redacted full and rejects others`() { #expect(CodexBarCLI.decodeDashboardIdentityMode(from: self.parsedValues(identity: nil)) == .redacted) @@ -124,6 +476,44 @@ struct DashboardClaudeSwapSnapshotTests { #expect(claude["error"] is NSNull) } + @Test + func `failed disambiguated accounts keep the raw email identity`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + alias: "Work", + isActive: true, + usageStatus: .tokenExpired, + fiveHour: nil, + sevenDay: nil), + ClaudeSwapAccountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + isActive: false, + usageStatus: .noCredentials, + fiveHour: nil, + sevenDay: nil), + ]) + let accounts = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.generatedAt) + let providers = try self.providers( + identityMode: .full, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + #expect(rows.compactMap { $0["label"] as? String } == ["Work", "shared@example.com · Acme"]) + #expect(rows.compactMap { ($0["identity"] as? [String: Any])?["accountEmail"] as? String } == [ + "shared@example.com", + "shared@example.com", + ]) + #expect((rows.last?["windows"] as? [Any])?.isEmpty == true) + #expect(rows.last?["updatedAt"] is NSNull) + } + @Test func `adapter failure adds only accounts error and preserves ambient fields`() throws { let providers = try self.providers( @@ -185,6 +575,30 @@ struct DashboardClaudeSwapSnapshotTests { #expect(claude["accountsError"] == nil) } + @Test + func `single account stays ambient unless explicitly enabled`() throws { + let account = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [self.accountRow(number: 1, email: "person@example.com", active: true)]), + now: self.generatedAt) + let hidden = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput(accounts: account, adapterError: nil, weeklyWorkDays: nil)) + let shown = try self.providers( + identityMode: .redacted, + claudeSwap: DashboardClaudeSwapInput( + accounts: account, + adapterError: nil, + weeklyWorkDays: nil, + showSingleAccount: true)) + + let hiddenClaude = try #require(hidden.first { $0["id"] as? String == "claude" }) + let shownClaude = try #require(shown.first { $0["id"] as? String == "claude" }) + #expect(hiddenClaude["accounts"] == nil) + #expect((shownClaude["accounts"] as? [Any])?.count == 1) + } + @Test func `producer collects swap accounts with config while its default omits them`() async throws { let recorder = DashboardClaudeSwapConfigRecorder() @@ -270,7 +684,11 @@ struct DashboardClaudeSwapSnapshotTests { { let providers = try self.providers( identityMode: mode, - claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + claudeSwap: DashboardClaudeSwapInput( + accounts: accounts, + adapterError: nil, + weeklyWorkDays: nil, + showSingleAccount: true)) let claude = try #require(providers.first { $0["id"] as? String == "claude" }) return try #require((claude["accounts"] as? [[String: Any]])?.first) } @@ -295,12 +713,16 @@ struct DashboardClaudeSwapSnapshotTests { private func accountRow( number: Int, email: String, + organizationName: String = "", + alias: String? = nil, active: Bool, scoped: [ClaudeSwapScopedUsageWindow] = []) -> ClaudeSwapAccountRow { ClaudeSwapAccountRow( number: number, email: email, + organizationName: organizationName, + alias: alias, isActive: active, usageStatus: .ok, fiveHour: ClaudeSwapUsageWindow( @@ -356,6 +778,7 @@ struct DashboardClaudeSwapSnapshotTests { private func enabledSwapConfig() -> CodexBarConfig { var claude = ProviderConfig(id: .claude, enabled: true) claude.claudeSwapEnabled = true + claude.claudeSwapShowSingleAccount = true return CodexBarConfig(providers: [claude]) } diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift index beb6a6711..624d6e82e 100644 --- a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -697,7 +697,11 @@ struct DashboardSnapshotBuilderTests { generatedAt: Date(timeIntervalSince1970: 0), refreshInterval: 60, codexBarVersion: nil, - claudeSwap: DashboardClaudeSwapInput(accounts: account, adapterError: nil, weeklyWorkDays: nil)) + claudeSwap: DashboardClaudeSwapInput( + accounts: account, + adapterError: nil, + weeklyWorkDays: nil, + showSingleAccount: true)) } private func firstClaudeSwapAccount(_ snapshot: DashboardSnapshotPayload) throws -> [String: Any] { diff --git a/Tests/CodexBarTests/FireworksUsageFetcherTests.swift b/Tests/CodexBarTests/FireworksUsageFetcherTests.swift index c8bcfb5ee..3e7c84198 100644 --- a/Tests/CodexBarTests/FireworksUsageFetcherTests.swift +++ b/Tests/CodexBarTests/FireworksUsageFetcherTests.swift @@ -219,18 +219,214 @@ struct FireworksUsageFetcherTests { } @Test - func `fetch usage requires key and slug`() async { - await #expect(throws: FireworksUsageError.missingCredentials) { + func `wrong slug with empty billing response is an explicit account error`() async throws { + defer { + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = nil + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [FireworksStubURLProtocol.self] + let session = URLSession(configuration: config) + + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = { request in + let url = try #require(request.url) + let body: String + if url.path == "/v1/accounts" { + body = #"{"accounts":[{"name":"accounts/actual-team"}]}"# + } else { + #expect(url.path == "/v1/accounts/guessed-user/billing/summary") + body = #"{"lineItems":[],"usageBuckets":[]}"# + } + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(body.utf8)) + } + + await #expect { _ = try await FireworksUsageFetcher.fetchUsage( - apiKey: " ", - accountSlug: "x0mh0x", - session: URLSession(configuration: .ephemeral)) + apiKey: "fw-test-key", + accountSlug: "guessed-user", + session: session) + } throws: { error in + guard error as? FireworksUsageError == .accountNotFound("guessed-user") else { return false } + return error.localizedDescription.hasPrefix( + "Fireworks account slug 'guessed-user' not found for this API key") + } + #expect(FireworksStubURLProtocol.requests.map(\.url?.path) == [ + "/v1/accounts/guessed-user/billing/summary", + "/v1/accounts", + ]) + } + + @Test + func `missing slug auto discovers a single account before fetching billing`() async throws { + defer { + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = nil + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [FireworksStubURLProtocol.self] + let session = URLSession(configuration: config) + + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = { request in + let url = try #require(request.url) + let body: String + if url.path == "/v1/accounts" { + body = #"{"accounts":[{"name":"accounts/discovered-team","displayName":"Discovered Team"}]}"# + } else { + #expect(url.path == "/v1/accounts/discovered-team/billing/summary") + body = """ + { + "lineItems": [ + { "totalCost": { "currencyCode": "USD", "nanos": 250000000, "units": "2" } } + ] + } + """ + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fw-test-key") + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(body.utf8)) + } + + let snapshot = try await FireworksUsageFetcher.fetchUsage( + apiKey: "fw-test-key", + accountSlug: nil, + session: session) + + #expect(snapshot.accountSlug == "discovered-team") + #expect(snapshot.accountSlugWasDiscovered) + #expect(snapshot.summary.last30DaysSpend == 2.25) + #expect(FireworksStubURLProtocol.requests.map(\.url?.path) == [ + "/v1/accounts", + "/v1/accounts/discovered-team/billing/summary", + ]) + } + + @Test + func `strategy returns discovered slug for app-owned persistence`() async throws { + defer { + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = nil } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FireworksStubURLProtocol.self] + let session = URLSession(configuration: configuration) + FireworksStubURLProtocol.handler = { request in + let url = try #require(request.url) + let body = if url.path == "/v1/accounts" { + #"{"accounts":[{"name":"accounts/discovered-team"}]}"# + } else { + #"{"lineItems":[{"totalCost":{"currencyCode":"USD","units":"1","nanos":0}}]}"# + } + return ( + HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, + Data(body.utf8)) + } + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 10, + webDebugDumpHTML: false, + verbose: false, + env: [FireworksSettingsReader.configAPIKeyEnvironmentKey: "fw-test-key"], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + let result = try await FireworksAPIFetchStrategy(transport: session).fetch(context) + + #expect(result.fireworksDiscoveredAccountSlug == "discovered-team") + #expect(result.sourceLabel.contains("auto-discovered")) + } + + @Test + func `multiple visible accounts report sorted slug candidates`() async throws { + defer { + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = nil + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [FireworksStubURLProtocol.self] + let session = URLSession(configuration: config) - await #expect(throws: FireworksUsageError.missingAccountSlug) { + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url.path == "/v1/accounts") + let body = #"{"accounts":[{"name":"accounts/zeta"},{"name":"accounts/alpha"}]}"# + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(body.utf8)) + } + + await #expect { _ = try await FireworksUsageFetcher.fetchUsage( apiKey: "fw-test-key", - accountSlug: "", + accountSlug: nil, + session: session) + } throws: { error in + guard error as? FireworksUsageError == .multipleAccountsFound(["alpha", "zeta"]) else { + return false + } + return error.localizedDescription.contains("alpha, zeta") + } + #expect(FireworksStubURLProtocol.requests.count == 1) + } + + @Test + func `configured 404 auto discovers the sole visible account`() async throws { + defer { + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = nil + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [FireworksStubURLProtocol.self] + let session = URLSession(configuration: config) + + FireworksStubURLProtocol.requests = [] + FireworksStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response: HTTPURLResponse + let body: String + switch url.path { + case "/v1/accounts/old-slug/billing/summary": + response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)! + body = #"{"code":5,"message":"account not found"}"# + case "/v1/accounts": + response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + body = #"{"accounts":[{"name":"accounts/current-slug"}]}"# + default: + #expect(url.path == "/v1/accounts/current-slug/billing/summary") + response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + body = #"{"lineItems":[{"totalCost":{"currencyCode":"USD","nanos":0,"units":"1"}}]}"# + } + return (response, Data(body.utf8)) + } + + let snapshot = try await FireworksUsageFetcher.fetchUsage( + apiKey: "fw-test-key", + accountSlug: "old-slug", + session: session) + + #expect(snapshot.accountSlug == "current-slug") + #expect(snapshot.accountSlugWasDiscovered) + #expect(snapshot.summary.last30DaysSpend == 1) + #expect(FireworksStubURLProtocol.requests.map(\.url?.path) == [ + "/v1/accounts/old-slug/billing/summary", + "/v1/accounts", + "/v1/accounts/current-slug/billing/summary", + ]) + } + + @Test + func `fetch usage requires key`() async { + await #expect(throws: FireworksUsageError.missingCredentials) { + _ = try await FireworksUsageFetcher.fetchUsage( + apiKey: " ", + accountSlug: "x0mh0x", session: URLSession(configuration: .ephemeral)) } } diff --git a/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift new file mode 100644 index 000000000..94f09a72d --- /dev/null +++ b/Tests/CodexBarTests/GrokLocalSessionScannerTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokLocalSessionScannerTests { + @Test + func `daily buckets stay local and never invent dollars`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-session-scan-\(UUID().uuidString)", isDirectory: true) + let cwd = root.appendingPathComponent("sessions/%2Ftmp%2Fdemo", isDirectory: true) + let first = cwd.appendingPathComponent("session-a", isDirectory: true) + let second = cwd.appendingPathComponent("session-b", isDirectory: true) + try FileManager.default.createDirectory(at: first, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + + let calendar = Calendar.current + let newer = Date(timeIntervalSince1970: 1_787_079_600) + let older = try #require(calendar.date(byAdding: .day, value: -1, to: newer)) + try self.writeSignals( + at: first.appendingPathComponent("signals.json"), + tokens: 100, + model: "grok-4.6", + date: older) + try self.writeSignals( + at: second.appendingPathComponent("signals.json"), + tokens: 250, + model: "grok-4.6", + date: newer) + + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + lookbackDays: 7, + now: newer) + #expect(summary.sessionCount == 2) + #expect(summary.totalTokens == 350) + #expect(summary.daily.map(\.totalTokens) == [100, 250]) + #expect(summary.daily.map(\.sessionCount) == [1, 1]) + #expect(Set(summary.daily.map(\.date)).count == 2) + + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.last30DaysTokens == 350) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.allSatisfy { $0.costUSD == nil }) + #expect(snapshot.costProvenance == .unknown) + #expect(snapshot.sessionTokens == 250) + } + + @Test + func `idle days do not reuse yesterday as today`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-session-idle-\(UUID().uuidString)", isDirectory: true) + let session = root.appendingPathComponent("sessions/%2Ftmp%2Fdemo/session-a", isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + let calendar = Calendar.current + let yesterday = Date(timeIntervalSince1970: 1_787_079_600) + let today = try #require(calendar.date(byAdding: .day, value: 1, to: yesterday)) + try self.writeSignals( + at: session.appendingPathComponent("signals.json"), + tokens: 100, + model: "grok-4.6", + date: yesterday) + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + lookbackDays: 7, + now: today) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: 7)) + #expect(snapshot.last30DaysTokens == 100) + #expect(snapshot.sessionTokens == nil) + } + + @Test + func `empty homes do not publish a spend snapshot`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-session-empty-\(UUID().uuidString)", isDirectory: true) + let summary = GrokLocalSessionScanner.summarize( + env: ["GROK_HOME": root.path], + lookbackDays: 7, + now: Date()) + #expect(summary.toCostUsageTokenSnapshot(historyDays: 7) == nil) + } + + @Test + func `local scan clock wins over a stale remote snapshot`() throws { + let calendar = Calendar.current + let staleRemoteTime = Date(timeIntervalSince1970: 1_787_079_600) + let localScanTime = try #require(calendar.date(byAdding: .day, value: 1, to: staleRemoteTime)) + let localDay = try #require(GrokLocalSessionScanner.dayKey(for: localScanTime, calendar: calendar)) + let summary = GrokLocalSessionSummary( + sessionCount: 1, + totalTokens: 250, + lastSessionAt: localScanTime, + primaryModel: "grok-4.6", + models: ["grok-4.6"], + daily: [GrokLocalDailyBucket( + date: localDay, + totalTokens: 250, + sessionCount: 1, + models: ["grok-4.6"])], + scannedAt: localScanTime) + let remote = GrokUsageSnapshot( + billing: nil, + credentials: nil, + localSummary: summary, + cliVersion: nil, + updatedAt: staleRemoteTime) + + let snapshot = try #require(remote.toUsageSnapshot().costUsage) + #expect(snapshot.sessionTokens == 250) + #expect(snapshot.updatedAt == localScanTime) + } + + private func writeSignals(at url: URL, tokens: Int, model: String, date: Date) throws { + let payload: [String: Any] = [ + "contextTokensUsed": tokens, + "totalTokensBeforeCompaction": 0, + "primaryModelId": model, + "modelsUsed": [model], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: url) + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + } +} diff --git a/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift new file mode 100644 index 000000000..c58a1a4f0 --- /dev/null +++ b/Tests/CodexBarTests/GrokXAISpendCatalogTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct GrokXAISpendCatalogTests { + @Test + func `grok and xai publish through the snapshot-backed spend catalog`() { + #expect(UsageStore.tokenCostRequiresProviderSnapshot(.grok)) + #expect(UsageStore.tokenCostRequiresProviderSnapshot(.xai)) + #expect(ProviderDescriptorRegistry.descriptor(for: .grok).tokenCost.supportsTokenCost) + #expect(ProviderDescriptorRegistry.descriptor(for: .xai).tokenCost.supportsTokenCost) + } + + @Test(.enabled( + if: ProcessInfo.processInfo.environment["CODEXBAR_LIVE_GROK_CATALOG_PROOF"] == "1", + "Set CODEXBAR_LIVE_GROK_CATALOG_PROOF=1 to scan local Grok sessions.")) + func `writes redacted live Grok catalog proof`() throws { + let summary = GrokLocalSessionScanner.summarize(lookbackDays: SpendDashboardSource.scanDays) + let snapshot = try #require(summary.toCostUsageTokenSnapshot(historyDays: SpendDashboardSource.scanDays)) + let model = SpendDashboardModel.build( + inputs: [.init(provider: .grok, displayName: "Grok", snapshot: snapshot)], + requestedDays: 30, + now: summary.scannedAt) + let grokRow = try #require(model.groups.flatMap(\.providers).first { $0.id == UsageProvider.grok.rawValue }) + + #expect(model.availableSources.map(\.id) == [UsageProvider.grok.rawValue]) + #expect(grokRow.totalTokens == snapshot.last30DaysTokens) + #expect(model.tokenActivity.contains { $0.totalTokens != nil }) + + print("catalog_source=grok") + print("today_tokens=\(snapshot.sessionTokens ?? 0)") + print("last_30_days_tokens=\(grokRow.totalTokens ?? 0)") + print("daily_buckets=\(snapshot.daily.count)") + print("available_sources=\(model.availableSources.map(\.id).joined(separator: ","))") + } +} diff --git a/Tests/CodexBarTests/KiroMenuCardModelTests.swift b/Tests/CodexBarTests/KiroMenuCardModelTests.swift index 032fd0ae5..b63b5ad6b 100644 --- a/Tests/CodexBarTests/KiroMenuCardModelTests.swift +++ b/Tests/CodexBarTests/KiroMenuCardModelTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct KiroMenuCardModelTests { @Test @@ -111,4 +111,55 @@ struct KiroMenuCardModelTests { #expect(model.providerDetails.flatMap(\.rows).contains { $0.label == "Overage usage" } == false) #expect(model.providerDetails.flatMap(\.rows).contains { $0.label == "Overage cost" } == false) } + + @Test + func `kiro model shows an overage gauge against its cap`() throws { + let now = Date() + let limits = try KiroUsageLimitsAPI.parse(Data(KiroUsageLimitsAPITests.overageInUseResponse.utf8)) + let snapshot = KiroUsageSnapshot( + planName: "KIRO POWER", + creditsUsed: 10000, + creditsTotal: 10000, + creditsPercent: 100, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + resetsAt: now.addingTimeInterval(3600), + updatedAt: now).withUsageLimits(limits).toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.kiro]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kiro, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + // The plan gauge is exhausted, but the overage gauge is what tells the user how much + // spendable headroom is left — the CLI report can show neither. + #expect(model.metrics.map(\.title) == ["Credits", "Overage"]) + let overage = try #require(model.metrics.last) + // `usageBarsShowUsed: false` is the default, so the gauge states what is left: 3603.49 of + // 10000 spent leaves 63.97%. + #expect(abs(overage.percent - 63.9651) < 0.0001) + #expect(overage.detailLeftText == "6396.51 of 10000 credits left") + // Kiro takes the default `.generic` cost style, which renders only when the limit is + // positive — the overage budget supplies one. + let cost = try #require(model.providerCost) + #expect(cost.spendLine.contains("$144.14")) + #expect(cost.spendLine.contains("$400.00")) + } } diff --git a/Tests/CodexBarTests/KiroUsageLimitsAPITests.swift b/Tests/CodexBarTests/KiroUsageLimitsAPITests.swift new file mode 100644 index 000000000..56a05205a --- /dev/null +++ b/Tests/CodexBarTests/KiroUsageLimitsAPITests.swift @@ -0,0 +1,408 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct KiroUsageLimitsAPITests { + /// Captured from a live `GetUsageLimits` response for a KIRO POWER account whose plan credits + /// were fully spent and whose overage was in use. Account identifiers are scrubbed. + static let overageInUseResponse = """ + {"daysUntilReset":0,"limits":[],"nextDateReset":1.7882208E9, + "overageConfiguration":{"__type":"com.amazon.aws.codewhisperer#OverageConfiguration", + "overageStatus":"ENABLED"}, + "subscriptionInfo":{"overageCapability":"OVERAGE_CAPABLE","subscriptionTitle":"KIRO POWER", + "type":"Q_DEVELOPER_STANDALONE_POWER"}, + "usageBreakdownList":[{"bonuses":[],"currency":"USD","currentOverages":3603, + "currentOveragesWithPrecision":3603.49,"currentUsage":13603, + "currentUsageWithPrecision":13603.49,"displayName":"Credit","nextDateReset":1.7882208E9, + "overageCap":10000,"overageCapWithPrecision":10000.0,"overageCharges":144.139711109352, + "overageCredits":[],"overageRate":0.04,"resourceType":"CREDIT","unit":"INVOCATIONS", + "usageLimit":10000,"usageLimitWithPrecision":10000.0}]} + """ + + @Test + func `parses plan and overage without double counting spend`() throws { + let limits = try KiroUsageLimitsAPI.parse(Data(Self.overageInUseResponse.utf8)) + + // `currentUsage` is the total including overage; the plan portion is the remainder. + #expect(limits.planLimit == 10000) + #expect(limits.planUsed == 10000) + #expect(limits.overageUsed == 3603.49) + #expect(limits.overageCap == 10000) + #expect(limits.overageEnabled == true) + #expect(limits.overageCharges == 144.139711109352) + #expect(limits.overageRate == 0.04) + #expect(limits.currencyCode == "USD") + #expect(limits.overageChargeLimit == 400) + #expect(limits.resetsAt == Date(timeIntervalSince1970: 1_788_220_800)) + } + + @Test + func `rejects overage that exceeds total usage`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences( + of: "\"currentUsageWithPrecision\":13603.49", + with: "\"currentUsageWithPrecision\":100") + #expect(throws: KiroUsageLimitsError.self) { + try KiroUsageLimitsAPI.parse(Data(json.utf8)) + } + } + + @Test + func `keeps overage that exceeds the overage cap`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences( + of: "\"overageCapWithPrecision\":10000.0", + with: "\"overageCapWithPrecision\":100") + let limits = try KiroUsageLimitsAPI.parse(Data(json.utf8)) + #expect(limits.overageCap == 100) + #expect(limits.overageUsed == 3603.49) + } + + @Test + func `treats overage as unavailable when the account has it disabled`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences(of: "\"overageStatus\":\"ENABLED\"", with: "\"overageStatus\":\"DISABLED\"") + let limits = try KiroUsageLimitsAPI.parse(Data(json.utf8)) + + #expect(limits.overageCap == nil) + #expect(limits.overageEnabled == false) + #expect(limits.overageChargeLimit == nil) + // Disabling overage does not un-spend it: `currentUsage` still includes those credits, so + // the plan portion stays the remainder and never reads above the plan limit. + #expect(limits.overageUsed == 3603.49) + #expect(limits.planUsed == 10000) + #expect(limits.planUsed <= limits.planLimit) + } + + @Test + func `rejects a reset outside plausible unix seconds`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences(of: "\"nextDateReset\":1.7882208E9", with: "\"nextDateReset\":1.7882208E12") + #expect(throws: KiroUsageLimitsError.self) { + try KiroUsageLimitsAPI.parse(Data(json.utf8)) + } + } + + @Test + func `rejects several credit balances`() throws { + // Two CREDIT rows leave no single authoritative ceiling, so neither may be picked. + let json = """ + {"nextDateReset":1.7882208E9,"usageBreakdownList":[ + {"resourceType":"CREDIT","currentUsageWithPrecision":1.0,"usageLimitWithPrecision":10.0}, + {"resourceType":"CREDIT","currentUsageWithPrecision":2.0,"usageLimitWithPrecision":20.0}]} + """ + #expect(throws: KiroUsageLimitsError.self) { + try KiroUsageLimitsAPI.parse(Data(json.utf8)) + } + } + + @Test + func `snapshot surfaces the overage window and charges against their ceilings`() throws { + let limits = try KiroUsageLimitsAPI.parse(Data(Self.overageInUseResponse.utf8)) + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-09-01 | KIRO POWER + Credits (10000.00 of 10000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 100% + """) + let snapshot = cliReport.withUsageLimits(limits).toUsageSnapshot() + + // The plan gauge must exclude overage, or 13603.49/10000 would read as 136%. + #expect(snapshot.primary?.usedPercent == 100) + let overage = try #require(snapshot.extraRateWindows?.first { $0.id == "kiro-overage" }) + #expect(overage.title == "Overage") + #expect(abs(overage.window.usedPercent - 36.0349) < 0.0001) + let cost = try #require(snapshot.providerCost) + #expect(cost.used == 144.139711109352) + #expect(cost.limit == 400) + #expect(cost.currencyCode == "USD") + + let rows = snapshot.details.flatMap(\.rows) + #expect(rows.contains { $0.label == "Overages" && $0.value == "Enabled" }) + #expect(rows.contains { $0.label == "Overage usage" && $0.value == "3603.49 credits" }) + #expect(rows.contains { $0.label == "Overage credits left" && $0.value == "6396.51" }) + } + + @Test + func `refuses the live state database under tests`() async { + await #expect(throws: KiroUsageLimitsError.self) { + try await KiroUsageLimitsAPI.fetch() + } + } + + @Test + func `cli report stands when the usage api is unavailable`() throws { + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-06-01 | KIRO FREE + Credits (0.17 of 50 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 0% + Overages: Disabled + """) + let snapshot = cliReport.withUsageLimits(nil).toUsageSnapshot() + + #expect(cliReport.creditsUsed == 0.17) + #expect(cliReport.creditsTotal == 50) + #expect(snapshot.extraRateWindows == nil) + #expect(snapshot.providerCost == nil) + } + + @Test + func `api disabled overage wins over a stale cli enabled status`() throws { + let limits = try KiroUsageLimitsAPI.parse(Data( + Self.overageInUseResponse + .replacingOccurrences(of: "\"overageStatus\":\"ENABLED\"", with: "\"overageStatus\":\"DISABLED\"") + .utf8)) + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-09-01 | KIRO POWER + Credits (10000.00 of 10000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 100% + Overages: Enabled billed at $0.04 per request + """) + let snapshot = cliReport.withUsageLimits(limits).toUsageSnapshot() + let rows = snapshot.details.flatMap(\.rows) + + #expect(limits.overageCap == nil) + #expect(snapshot.extraRateWindows == nil) + #expect(snapshot.providerCost == nil) + #expect(rows.contains { $0.label == "Overages" && $0.value == "Disabled" }) + #expect(rows.contains { $0.label == "Overage usage" } == false) + #expect(rows.contains { $0.label == "Overage credits left" } == false) + } + + @Test + func `rejects plan usage above the reported plan limit`() { + let json = Self.overageInUseResponse + .replacingOccurrences( + of: "\"currentOveragesWithPrecision\":3603.49", + with: "\"currentOveragesWithPrecision\":0") + #expect(throws: KiroUsageLimitsError.self) { + try KiroUsageLimitsAPI.parse(Data(json.utf8)) + } + } + + @Test + func `unknown overage status is not treated as disabled`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences( + of: "\"overageStatus\":\"ENABLED\"", + with: "\"overageStatus\":\"FUTURE_STATUS\"") + let limits = try KiroUsageLimitsAPI.parse(Data(json.utf8)) + #expect(limits.overageEnabled == nil) + #expect(limits.overageCap == nil) + + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-09-01 | KIRO POWER + Credits (10000.00 of 10000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 100% + Overages: Enabled billed at $0.04 per request + """) + let snapshot = cliReport.withUsageLimits(limits).toUsageSnapshot() + let rows = snapshot.details.flatMap(\.rows) + #expect(rows.contains { $0.label == "Overages" && $0.value.hasPrefix("Enabled") }) + #expect(rows.contains { $0.label == "Overage usage" }) + } + + @Test + func `enabled overage without a cap keeps cli overage rows`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences(of: "\"overageCapWithPrecision\":10000.0,", with: "") + let limits = try KiroUsageLimitsAPI.parse(Data(json.utf8)) + #expect(limits.overageEnabled == nil) + #expect(limits.overageCap == nil) + #expect(limits.overageUsed == 3603.49) + + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-09-01 | KIRO POWER + Credits (10000.00 of 10000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 100% + Overages: Enabled billed at $0.04 per request + """) + let snapshot = cliReport.withUsageLimits(limits).toUsageSnapshot() + let rows = snapshot.details.flatMap(\.rows) + #expect(rows.contains { $0.label == "Overages" && $0.value.hasPrefix("Enabled") }) + #expect(rows.contains { $0.label == "Overage usage" }) + #expect(rows.contains { $0.label == "Overage credits left" } == false) + } + + @Test + func `bonus entries keep cli plan usage instead of mixing them into the plan gauge`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences(of: "\"bonuses\":[]", with: "\"bonuses\":[{}]") + let limits = try KiroUsageLimitsAPI.parse(Data(json.utf8)) + #expect(limits.hasUnseparatedBonus) + #expect(limits.overageUsed == 3603.49) + + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-09-01 | KIRO POWER + Credits (40.00 of 10000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 0% + Bonus credits: 5.00/10 credits used + Overages: Enabled billed at $0.04 per request + """) + let snapshot = cliReport.withUsageLimits(limits) + #expect(snapshot.creditsUsed == 40) + #expect(snapshot.creditsTotal == 10000) + #expect(snapshot.bonusCreditsUsed == 5) + #expect(snapshot.overageCreditsUsed == 3603.49) + let rows = snapshot.toUsageSnapshot().details.flatMap(\.rows) + #expect(rows.contains { $0.label == "Overage usage" }) + } + + @Test + func `bonus-inclusive usage above the plan limit still enriches overage`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences(of: "\"bonuses\":[]", with: "\"bonuses\":[{}]") + .replacingOccurrences( + of: "\"currentUsageWithPrecision\":13603.49", + with: "\"currentUsageWithPrecision\":14603.49") + let limits = try KiroUsageLimitsAPI.parse(Data(json.utf8)) + #expect(limits.hasUnseparatedBonus) + #expect(limits.planUsed == 11000) + #expect(limits.overageUsed == 3603.49) + #expect(limits.overageCap == 10000) + + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-09-01 | KIRO POWER + Credits (40.00 of 10000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 0% + Bonus credits: 5.00/10 credits used + Overages: Enabled billed at $0.04 per request + """) + let snapshot = cliReport.withUsageLimits(limits) + #expect(snapshot.creditsUsed == 40) + #expect(snapshot.creditsTotal == 10000) + #expect(snapshot.overageCreditsUsed == 3603.49) + } + + @Test + func `non usd api without charges does not keep the cli usd estimate`() throws { + let json = Self.overageInUseResponse + .replacingOccurrences(of: "\"currency\":\"USD\"", with: "\"currency\":\"EUR\"") + .replacingOccurrences(of: "\"overageCharges\":144.139711109352,", with: "") + let limits = try KiroUsageLimitsAPI.parse(Data(json.utf8)) + #expect(limits.currencyCode == "EUR") + #expect(limits.overageCharges == nil) + + let probe = KiroStatusProbe() + let cliReport = try probe.parse(output: """ + Estimated Usage | resets on 2026-09-01 | KIRO POWER + Credits (10000.00 of 10000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 100% + + Overages: Enabled billed at $0.04 per request + Credits used: 40.29 + Est. cost: $1.61 USD + """) + #expect(cliReport.estimatedOverageCostUSD == 1.61) + + let snapshot = cliReport.withUsageLimits(limits) + #expect(snapshot.estimatedOverageCostUSD == nil) + #expect(snapshot.usageLimits?.currencyCode == "EUR") + } + + @Test + func `resolves kiro cli state database per platform and overrides`() { + let home = URL(fileURLWithPath: "/tmp/codexbar-kiro-home", isDirectory: true) + let mac = KiroUsageLimitsAPI.stateDatabaseURL( + homeDirectory: home, + environment: [:], + usesMacOSApplicationSupport: true) + #expect(mac.path == "/tmp/codexbar-kiro-home/Library/Application Support/kiro-cli/data.sqlite3") + + let linux = KiroUsageLimitsAPI.stateDatabaseURL( + homeDirectory: home, + environment: [:], + usesMacOSApplicationSupport: false) + #expect(linux.path == "/tmp/codexbar-kiro-home/.local/share/kiro-cli/data.sqlite3") + + let xdg = KiroUsageLimitsAPI.stateDatabaseURL( + homeDirectory: home, + environment: ["XDG_DATA_HOME": "/tmp/xdg-data"], + usesMacOSApplicationSupport: false) + #expect(xdg.path == "/tmp/xdg-data/kiro-cli/data.sqlite3") + + let override = KiroUsageLimitsAPI.stateDatabaseURL( + homeDirectory: home, + environment: ["KIRO_DATA_DIR": "/tmp/kiro-data"], + usesMacOSApplicationSupport: true) + #expect(override.path == "/tmp/kiro-data/data.sqlite3") + } + + @Test + func `fetch enriches plan and overage from the usage api`() async throws { + let limits = try KiroUsageLimitsAPI.parse(Data(Self.overageInUseResponse.utf8)) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-limits-\(UUID().uuidString)", isDirectory: true) + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-09-01 | KIRO POWER\\n' + printf 'Credits (10000.00 of 10000 covered in plan)\\n' + printf '████████████████████████████████████████████████████████████████████████████████ 100%%\\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + + let snapshot = try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + usageLimitsFetcher: { limits }).fetch() + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.creditsUsed == 10000) + #expect(snapshot.overageCreditsUsed == 3603.49) + #expect(snapshot.usageLimits?.overageCap == 10000) + #expect(usage.extraRateWindows?.contains { $0.id == "kiro-overage" } == true) + #expect(usage.providerCost?.limit == 400) + } + + @Test + func `cancellation during usage limits enrichment is preserved`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-limits-cancel-\(UUID().uuidString)", isDirectory: true) + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-09-01 | KIRO POWER\\n' + printf 'Credits (10000.00 of 10000 covered in plan)\\n' + printf '████████████████████████████████████████████████████████████████████████████████ 100%%\\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + + await #expect(throws: CancellationError.self) { + try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + usageLimitsFetcher: { throw CancellationError() }).fetch() + } + } +} diff --git a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift index 8ec324ea1..ff3f55194 100644 --- a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift +++ b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift @@ -71,6 +71,67 @@ struct MenuBarCountdownRefreshTests { #expect(abs((delay ?? 0) - 3600.05) < 0.001) } + @Test + func `weekly pace refresh delay targets the one percent boundary`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let duration = TimeInterval(10080 * 60) + let window = RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(duration), + resetDescription: nil) + + let delay = StatusItemController.menuBarPaceRefreshDelay(window: window, now: now) + + #expect(abs((delay ?? 0) - (duration * 0.01 + 0.05)) < 0.001) + } + + @Test + func `direct weekly pace token schedules its elapsed eligibility refresh`() throws { + let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-weekly-pace-boundary") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarIconStyle = .iconAndPercent + settings.mergeIcons = false + settings.selectedMenuProvider = .claude + settings.setMenuBarLayout(MenuBarLayout(lines: [[.icon, .pace(window: .weekly)]]), for: .claude) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(ProviderRegistry.shared.metadata[.claude]), + enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + let duration = TimeInterval(10080 * 60) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(duration), + resetDescription: nil), + updatedAt: now), + provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + controller.updateIcons() + + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } + @Test func `status item schedules countdown and exhausted lane refreshes`() { let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-scheduling") @@ -451,6 +512,99 @@ struct MenuBarCountdownRefreshTests { #expect(controller._test_isMenuBarCountdownRefreshScheduled()) } + /// A pace or run-out predicate compares a clock-derived value, so it needs a tick even when the + /// layout carries no pace or reset token to trigger the token-gated schedulers. + @Test(arguments: [MenuBarConditionalMetric.runsOutIn, .weeklyPace, .sessionPace, .automaticPace]) + func `predicate-only clock-derived conditional schedules a refresh`(metric: MenuBarConditionalMetric) { + let controller = Self.makePredicateOnlyController( + suite: "MenuBarCountdownRefreshTests-predicate-only-\(metric.rawValue)", + metric: metric) + defer { controller.releaseStatusItemsForTesting() } + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } + + /// Money predicates move only when new provider data arrives, so they must not pin a clock tick. + @Test + func `predicate-only cost conditional schedules nothing`() { + let controller = Self.makePredicateOnlyController( + suite: "MenuBarCountdownRefreshTests-predicate-only-cost", + metric: .costToday) + defer { controller.releaseStatusItemsForTesting() } + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + } + + /// A reset-countdown predicate gets an exact wake-up at `resetsAt - threshold` rather than a tick. + @Test + func `predicate-only reset countdown conditional schedules its flip instant`() { + let controller = Self.makePredicateOnlyController( + suite: "MenuBarCountdownRefreshTests-predicate-only-reset", + metric: .sessionResetsIn, + threshold: 0.25) + defer { controller.releaseStatusItemsForTesting() } + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } + + /// Places a single conditional whose only clause reads `metric`, with no pace, reset, or countdown + /// token anywhere in the layout, so the token-gated schedulers cannot be what fires. + private static func makePredicateOnlyController( + suite: String, + metric: MenuBarConditionalMetric, + threshold: Double = 1) + -> StatusItemController + { + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + if let metadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + } + + let conditional = MenuBarLayoutConditional( + name: "gate", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: metric, + comparison: .lessThan, + threshold: threshold))], + thenToken: .percent(window: .session), + elseToken: .hidden) + settings.menuBarLayoutConditionals = [conditional] + settings.menuBarLayout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + return controller + } + @Test func `merged highest usage observes reset for noncurrent Codex candidate`() throws { let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-merged-highest") diff --git a/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift b/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift index e84ebd42d..3c76c01db 100644 --- a/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift @@ -156,6 +156,25 @@ struct MenuBarLayoutEditorTests { #expect(decoded == payload) } + @Test + func `conditional drag payload round trips`() throws { + let payload = MenuBarLayoutDragItem.palette(.conditional(id: UUID())) + + let data = try JSONEncoder().encode(payload) + #expect(try JSONDecoder().decode(MenuBarLayoutDragItem.self, from: data) == payload) + } + + @Test + func `conditional token appends like palette tokens`() { + let initial = MenuBarLayout(lines: [[.icon, .resetCountdown]]) + let conditionalID = UUID() + + let appended = MenuBarLayoutEditorMutations.append( + .conditional(id: conditionalID), + to: initial) + #expect(appended.lines == [[.icon, .resetCountdown, .conditional(id: conditionalID)]]) + } + @Test func `balance token is provider aware`() throws { let row = try ProviderDetailSection.Row(label: "Remaining", value: "$12.34") @@ -170,4 +189,31 @@ struct MenuBarLayoutEditorTests { #expect(MenuBarLayoutBalanceResolver.balance(provider: .codex, snapshot: snapshot) == nil) #expect(MenuBarLayoutToken.balance.editorLabel(provider: nil) == L("Balance")) } + + @Test + func `conditional palette chips wrap instead of overflowing the pane`() { + let spacing: CGFloat = 6 + + // Two 100pt chips fit in 220pt (100 + 6 + 100); the third has to wrap. + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [100, 100, 100], + maxWidth: 220, + spacing: spacing) == [[0, 1], [2]]) + + // Long localized names still get placed on their own row rather than dropped. + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [400], + maxWidth: 220, + spacing: spacing) == [[0]]) + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [400, 120], + maxWidth: 220, + spacing: spacing) == [[0], [1]]) + + // Chips that fit stay on one row, so a short library keeps hugging the leading edge. + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [80, 90], + maxWidth: 220, + spacing: spacing) == [[0, 1]]) + } } diff --git a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift index fe0747b7d..ed0ca8128 100644 --- a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift @@ -6,6 +6,7 @@ import Testing @MainActor @Suite(.serialized) +// swiftlint:disable:next type_body_length struct MenuBarLayoutRendererTests { private let now = Date(timeIntervalSince1970: 1_752_768_000) @@ -225,7 +226,8 @@ struct MenuBarLayoutRendererTests { runsOut: nil, balance: nil, costToday: nil, - cost30d: nil) + cost30d: nil, + metrics: .unavailable) let layout = MenuBarLayout(lines: [[ .icon, .providerName, @@ -315,7 +317,8 @@ struct MenuBarLayoutRendererTests { runsOut: nil, balance: nil, costToday: nil, - cost30d: nil) + cost30d: nil, + metrics: .unavailable) let output = renderer.render( layout: MenuBarLayout(lines: [[.percent(window: .session), .separatorDot, .pace(window: .session)]]), @@ -361,6 +364,53 @@ struct MenuBarLayoutRendererTests { #expect(bounds.height <= 22) } + @Test + func `icon above automatic percentages stays in the attributed two line layout`() { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + icon.isTemplate = true + let output = renderer.render( + layout: MenuBarLayout(lines: [ + [.icon], + [ + .percent(window: .automatic), + .percent(window: .session), + .percent(window: .weekly), + .lanePercent(lane: .primary), + ], + ]), + data: self.data(), + icon: icon, + options: self.options()) + + #expect(output.leadingIcon == nil) + #expect(output.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) is NSTextAttachment) + #expect(output.attributedTitle.string == "\u{FFFC}\n50%\u{2009}5h 25%\u{2009}W 60%\u{2009}10%") + #expect(output.accessibilityLabel.contains(L("menu_bar_layout_line", 2))) + } + + @Test + func `single line icon and automatic percentages keep the surfaced image`() throws { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + icon.isTemplate = true + let output = renderer.render( + layout: MenuBarLayout(lines: [[ + .icon, + .percent(window: .automatic), + .percent(window: .session), + .percent(window: .weekly), + ]]), + data: self.data(), + icon: icon, + options: self.options()) + + let leadingIcon = try #require(output.leadingIcon) + #expect(leadingIcon === icon) + #expect(output.attributedTitle.string == "\u{2009}50%\u{2009}5h 25%\u{2009}W 60%") + #expect(output.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) == nil) + } + @Test func `stacked titles apply a vertical centering offset`() throws { let renderer = MenuBarLayoutRenderer() @@ -496,6 +546,7 @@ struct MenuBarLayoutRendererTests { size: .regular, highContrast: false, showUsed: false, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: self.now)) @@ -531,7 +582,8 @@ struct MenuBarLayoutRendererTests { runsOut: nil, balance: nil, costToday: nil, - cost30d: nil) + cost30d: nil, + metrics: .unavailable) let output = renderer.render( layout: MenuBarLayout(lines: [[.resetAbsolute]]), @@ -551,6 +603,7 @@ struct MenuBarLayoutRendererTests { size: options.size, highContrast: true, showUsed: options.showUsed, + conditionals: options.conditionals, appearanceName: options.appearanceName, isDebugApp: options.isDebugApp, now: options.now) @@ -606,11 +659,692 @@ struct MenuBarLayoutRendererTests { .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .controlTextColor) } + @Test + func `conditional renders then branch when predicate true`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "5h 25%") + } + + @Test + func `conditional renders else branch when predicate false`() { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so a > 50 threshold fails and the else branch must win. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "in 2h") + } + + @Test + func `hidden branch renders nothing`() { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so > 50 fails and the else branch (.hidden) wins, contributing nothing. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])) + #expect(output.attributedTitle.string.isEmpty) + } + + @Test + func `conditional and requires all predicates`() { + let renderer = MenuBarLayoutRenderer() + let data = self.data() + + // Session 25% > 0 (true) AND weekly 60% > 70 (false) -> whole clause false. + let falseConditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 0), + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 70, combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let falseOutput = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: falseConditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [falseConditional])) + let falseControl = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: data, + icon: nil, + options: self.options()) + #expect(falseOutput.attributedTitle.string == falseControl.attributedTitle.string) + + // Session 25% > 0 (true) AND weekly 60% > 50 (true) -> all predicates pass. + let trueConditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 0), + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 50, combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let trueOutput = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: trueConditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [trueConditional])) + let trueControl = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + #expect(trueOutput.attributedTitle.string == trueControl.attributedTitle.string) + #expect(trueOutput.attributedTitle.string == "5h 25%") + } + + @Test + func `conditional or accepts any predicate`() { + let renderer = MenuBarLayoutRenderer() + // Weekly 60% > 70 (false) OR session 25% > 0 (true) -> whole clause true. + let conditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 70), + self.clause(metric: .session, comparison: .greaterThan, threshold: 0, combinator: .or), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "5h 25%") + } + + @Test + func `conditional with missing metric window falls to else`() { + let renderer = MenuBarLayoutRenderer() + // Session window is nil, so the session predicate evaluates false (not "0 > 0"). + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = MenuBarLayoutRenderData( + iconKey: "missing", + providerName: nil, + accountLabel: nil, + laneLabels: MenuBarLayoutLaneLabels(provider: .codex, snapshot: nil), + primary: nil, + secondary: nil, + tertiary: nil, + session: nil, + weekly: nil, + scopedWeekly: nil, + scopedWeeklyTitle: nil, + automatic: nil, + automaticText: nil, + sessionPace: nil, + weeklyPace: nil, + automaticPace: nil, + runsOut: nil, + balance: nil, + costToday: nil, + cost30d: nil, + metrics: .unavailable) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "–") + } + + @Test + func `nested conditional depth cap renders placeholder`() { + let renderer = MenuBarLayoutRenderer() + // A conditionals entry may reference itself through its branches; the renderer caps + // traversal at maxConditionalDepth. + let selfID = UUID() + let looping = MenuBarLayoutConditional( + id: selfID, + name: "loop", + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .conditional(id: selfID), + elseToken: .space) + let data = self.data() + // The cap triggers before any branch is evaluated, independent of live values, + // so the missing-value placeholder is the expected title. + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: selfID)]]), + data: data, + icon: nil, + options: self.options(conditionals: [looping])) + + #expect(output.attributedTitle.string == "–") + } + + @Test + func `conditional accessibility announces the chosen branch`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.accessibilityLabel == control.accessibilityLabel) + #expect(output.accessibilityLabel == L("%@ %@", L("Session"), "25%")) + } + + @Test + func `hidden branch leaves no orphaned spacing`() { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so a > 50 threshold fails and the else branch (.hidden) wins: the + // conditional contributes nothing, and the neighbors must join without a stray separator. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + let options = self.options(conditionals: [conditional]) + + let middle = renderer.render( + layout: MenuBarLayout(lines: [[ + .percent(window: .session), + .conditional(id: conditional.id), + .percent(window: .weekly), + ]]), + data: self.data(), + icon: nil, + options: options) + #expect(middle.attributedTitle.string == "5h 25%\u{2009}W 60%") + + let trailing = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session), .conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: options) + #expect(trailing.attributedTitle.string == "5h 25%") + } + + @Test + func `a line emptied by a hidden branch collapses instead of rendering blank`() throws { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so > 50 fails and the else branch (.hidden) wins on the conditional line. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + let options = self.options(conditionals: [conditional]) + + // Leading line hidden: no leading newline, and no empty VoiceOver line. + let leadingHidden = renderer.render( + layout: MenuBarLayout(lines: [ + [.conditional(id: conditional.id)], + [.percent(window: .weekly)], + ]), + data: self.data(), + icon: nil, + options: options) + #expect(leadingHidden.attributedTitle.string == "W 60%") + #expect(!leadingHidden.accessibilityLabel.contains(L("menu_bar_layout_line", 2))) + + // Trailing line hidden: no trailing newline. + let trailingHidden = renderer.render( + layout: MenuBarLayout(lines: [ + [.percent(window: .weekly)], + [.conditional(id: conditional.id)], + ]), + data: self.data(), + icon: nil, + options: options) + #expect(trailingHidden.attributedTitle.string == "W 60%") + + // A collapsed layout also drops stacked typography: it matches the single-line control. + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .weekly)]]), + data: self.data(), + icon: nil, + options: options) + let collapsedOffset = try #require(self.baselineOffset(in: leadingHidden.attributedTitle, at: 0)) + let controlOffset = try #require(self.baselineOffset(in: control.attributedTitle, at: 0)) + #expect(collapsedOffset == controlOffset) + #expect(leadingHidden.accessibilityLabel == control.accessibilityLabel) + } + + @Test + func `every line hidden renders an empty title without crashing`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])) + #expect(output.attributedTitle.string.isEmpty) + #expect(output.accessibilityLabel.isEmpty) + + // The debug marker has no line to attach to once everything collapsed. + let debug = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional], isDebugApp: true)) + #expect(debug.attributedTitle.string == " D") + #expect(debug.accessibilityLabel == L("Debug")) + } + + @Test + func `a conditional resolving to the icon surfaces it as the leading image`() throws { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + icon.isTemplate = true + // Session is 25%, so > 0 passes and the then branch (.icon) wins in first position. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .icon, + elseToken: .hidden) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id), .percent(window: .automatic)]]), + data: self.data(), + icon: icon, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]), + data: self.data(), + icon: icon, + options: self.options()) + + // AppKit only dims `button.image` on inactive displays, so a resolved icon has to take the + // same path a literal `.icon` token takes rather than becoming an attributed attachment. + let resolvedIcon = try #require(output.leadingIcon) + let controlIcon = try #require(control.leadingIcon) + #expect(resolvedIcon === controlIcon) + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) == nil) + #expect(output.accessibilityLabel == control.accessibilityLabel) + } + + @Test + func `dangling conditional reference renders placeholder`() { + let renderer = MenuBarLayoutRenderer() + // An id not present in the library has nothing to resolve; the renderer must show the + // missing-value placeholder instead of emitting a branch. + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: UUID())]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [])) + + #expect(output.attributedTitle.string == "–") + #expect(output.accessibilityLabel.contains(L("menu_bar_layout_conditional_unavailable"))) + } + + @Test + func `library edit changes the rendered branch without touching the layout`() { + let renderer = MenuBarLayoutRenderer() + let id = UUID() + // The layout only stores the reference; the library entry decides the branch. + let layout = MenuBarLayout(lines: [[.conditional(id: id)]]) + + let conditionTrue = MenuBarLayoutConditional( + id: id, + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let first = renderer.render( + layout: layout, + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditionTrue])) + #expect(first.attributedTitle.string == "5h 25%") + + // Flip the comparison so the same layout now renders the else branch. + let conditionFalse = MenuBarLayoutConditional( + id: id, + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let second = renderer.render( + layout: layout, + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditionFalse])) + #expect(second.attributedTitle.string != first.attributedTitle.string) + #expect(second.attributedTitle.string == "in 2h") + } + + @Test + func `mixed and or evaluates as a left fold`() { + let renderer = MenuBarLayoutRenderer() + // Session 25% > 0 (T) or weekly 60% > 90 (F) and automatic 50% > 90 (F). + // The left fold yields ((T ∨ F) ∧ F) = false, so the else branch wins; conventional + // precedence (T ∨ (F ∧ F) = true) would pick the then branch instead. + let conditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 0), + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 90, combinator: .or), + self.clause(metric: .automatic, comparison: .greaterThan, threshold: 90, combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: self.data(), + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "in 2h") + } + + /// The fixture's session resets one hour out, so a `< 2h` countdown predicate holds. Rewinding the + /// clock four hours puts the reset five hours out and the same predicate must stop holding. + @Test + func `resets-in predicate picks the then branch inside the threshold`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .sessionResetsIn, comparison: .lessThan, threshold: 2)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let layout = MenuBarLayout(lines: [[.conditional(id: conditional.id)]]) + + let inside = renderer.render( + layout: layout, + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])) + #expect(inside.attributedTitle.string == "5h 25%") + + let outside = renderer.render( + layout: layout, + data: self.data(), + icon: nil, + options: self.options( + now: self.now.addingTimeInterval(-4 * 60 * 60), + conditionals: [conditional])) + #expect(outside.attributedTitle.string == "in 6h") + } + + @Test + func `session percent and resets-in combine with and`() { + let renderer = MenuBarLayoutRenderer() + let layout = { (conditional: MenuBarLayoutConditional) in + MenuBarLayout(lines: [[.conditional(id: conditional.id)]]) + } + let passing = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 20), + self.clause( + metric: .sessionResetsIn, + comparison: .lessThan, + threshold: 2, + combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let failing = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 90), + self.clause( + metric: .sessionResetsIn, + comparison: .lessThan, + threshold: 2, + combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + + let then = renderer.render( + layout: layout(passing), + data: self.data(), + icon: nil, + options: self.options(conditionals: [passing])) + #expect(then.attributedTitle.string == "5h 25%") + + let otherwise = renderer.render( + layout: layout(failing), + data: self.data(), + icon: nil, + options: self.options(conditionals: [failing])) + #expect(otherwise.attributedTitle.string == "in 2h") + } + + /// Session is 25% used, so 75% remains: the same threshold must flip with the direction. + @Test + func `remaining direction inverts the percent reading`() { + #expect(self.branchText(self.clause( + metric: .session, + comparison: .greaterThan, + threshold: 50, + direction: .remaining)) == "5h 25%") + #expect(self.branchText(self.clause( + metric: .session, + comparison: .greaterThan, + threshold: 50, + direction: .used)) == "in 2h") + } + + @Test + func `balance direction selects used or remaining amount`() { + #expect(self.branchText(self.clause( + metric: .balance, + comparison: .greaterThan, + threshold: 10, + direction: .remaining)) == "5h 25%") + #expect(self.branchText(self.clause( + metric: .balance, + comparison: .greaterThan, + threshold: 10, + direction: .used)) == "in 2h") + } + + @Test + func `pace run-out and cost predicates read numeric metrics`() { + #expect(self.branchText(self.clause( + metric: .weeklyPace, + comparison: .greaterThan, + threshold: 10)) == "5h 25%") + // 2400 minutes == 40 hours. + #expect(self.branchText(self.clause( + metric: .runsOutIn, + comparison: .lessThan, + threshold: 48)) == "5h 25%") + #expect(self.branchText(self.clause( + metric: .runsOutIn, + comparison: .lessThan, + threshold: 12)) == "in 2h") + #expect(self.branchText(self.clause( + metric: .costToday, + comparison: .greaterThanOrEqual, + threshold: 1)) == "5h 25%") + #expect(self.branchText(self.clause( + metric: .tertiaryLane, + comparison: .greaterThan, + threshold: 10)) == "5h 25%") + } + + @Test + func `predicate on a metric with no datum evaluates false`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .cost30d, comparison: .greaterThanOrEqual, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(metrics: .unavailable), + icon: nil, + options: self.options(conditionals: [conditional])) + #expect(output.attributedTitle.string == "in 2h") + } + + /// Regression guard for the title cache: a countdown predicate flips with nothing but the clock, and + /// the automatic window's reset text — the only time-derived key component before this — does not + /// distinguish the two renders here. + @Test + func `time based conditional flips when only the clock advances`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .weeklyResetsIn, comparison: .lessThan, threshold: 48)], + thenToken: .percent(window: .session), + elseToken: .hidden) + let layout = MenuBarLayout(lines: [[.conditional(id: conditional.id)]]) + let data = self.data() + + // Weekly resets 3 days out: 72h > 48h, so the else branch hides the token. + let before = renderer.render( + layout: layout, + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + #expect(before.attributedTitle.string.isEmpty) + + // Two days later the same weekly reset is 24h out and the then branch must win. + let after = renderer.render( + layout: layout, + data: data, + icon: nil, + options: self.options( + now: self.now.addingTimeInterval(2 * 24 * 60 * 60), + conditionals: [conditional])) + #expect(after.attributedTitle.string == "5h 25%") + } + + /// Renders a single-clause conditional whose then branch is the session percent and whose else + /// branch is the automatic reset countdown, so a caller can assert which branch won by text. + private func branchText(_ clause: MenuBarConditionalClause) -> String { + let conditional = MenuBarLayoutConditional( + clauses: [clause], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + return MenuBarLayoutRenderer().render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])).attributedTitle.string + } + + /// End-to-end proof for the shipped "Auto % / Resets in" default: while the automatic lane still has + /// headroom it renders the percentage, and once the quota is spent it swaps to the reset countdown. + @Test + func `shipped auto default swaps percent for the countdown once the quota is spent`() { + let renderer = MenuBarLayoutRenderer() + let shipped = MenuBarLayoutConditional.shippedLibrary() + guard let auto = shipped.first(where: { entry in + entry.clauses.contains { $0.predicate.direction == .remaining } + }) else { + Issue.record("expected a shipped automatic remaining-direction conditional") + return + } + #expect(auto.displayName == "Auto % / Resets in") + let layout = MenuBarLayout(lines: [[.conditional(id: auto.id)]]) + + let withHeadroom = renderer.render( + layout: layout, + data: self.data(), + icon: nil, + options: self.options(conditionals: [auto])) + #expect(withHeadroom.attributedTitle.string == "50%") + + let spent = renderer.render( + layout: layout, + data: self.data(automaticUsedPercent: 100), + icon: nil, + options: self.options(conditionals: [auto])) + #expect(spent.attributedTitle.string == "in 2h") + } + + private func clause( + metric: MenuBarConditionalMetric, + comparison: MenuBarConditionalComparison, + threshold: Double, + direction: MenuBarConditionalDirection = .used, + combinator: MenuBarConditionalCombinator? = nil) -> MenuBarConditionalClause + { + MenuBarConditionalClause( + combinator: combinator, + predicate: MenuBarConditionalPredicate( + metric: metric, + direction: direction, + comparison: comparison, + threshold: threshold)) + } + private func data( automaticUsedPercent: Double = 50, provider: UsageProvider = .codex, laneLabels: MenuBarLayoutLaneLabels? = nil, - automaticResetAt: Date? = nil) + automaticResetAt: Date? = nil, + metrics: MenuBarLayoutRenderMetrics? = nil) -> MenuBarLayoutRenderData { MenuBarLayoutRenderData( @@ -661,20 +1395,33 @@ struct MenuBarLayoutRendererTests { runsOut: "Runs out in 1d 16h", balance: "$12.34", costToday: "$1.25", - cost30d: "$20.00") + cost30d: "$20.00", + // Numeric twins of the strings above, so conditional predicates and rendered text agree. + metrics: metrics ?? MenuBarLayoutRenderMetrics( + sessionPaceDelta: -8, + weeklyPaceDelta: 11, + automaticPaceDelta: 0, + runsOutMinutes: 2400, + balanceRemainingUSD: 12.34, + balanceUsedUSD: 7.66, + costTodayUSD: 1.25, + cost30dUSD: 20)) } private func options( now: Date? = nil, verticalAdjustment: Int = 0, - isStale: Bool = false) -> MenuBarLayoutRenderOptions + isStale: Bool = false, + conditionals: [MenuBarLayoutConditional] = [], + isDebugApp: Bool = false) -> MenuBarLayoutRenderOptions { MenuBarLayoutRenderOptions( size: .regular, highContrast: false, showUsed: true, + conditionals: conditionals, appearanceName: "aqua", - isDebugApp: false, + isDebugApp: isDebugApp, isStale: isStale, now: now ?? self.now, verticalAdjustment: verticalAdjustment) diff --git a/Tests/CodexBarTests/MenuBarLayoutTests.swift b/Tests/CodexBarTests/MenuBarLayoutTests.swift index 44afc6d05..e55307c1c 100644 --- a/Tests/CodexBarTests/MenuBarLayoutTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable:next type_body_length struct MenuBarLayoutTests { private struct UnnormalizedLayout: Encodable { let lines: [[MenuBarLayoutToken]] @@ -55,6 +56,494 @@ struct MenuBarLayoutTests { #expect(try JSONDecoder().decode(MenuBarLayoutToken.self, from: labeled) == .runsOut) } + @Test + func `conditional token codable round trips`() throws { + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let layout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + let data = try JSONEncoder().encode(layout) + let decoded = try JSONDecoder().decode(MenuBarLayout.self, from: data) + + #expect(decoded == layout) + let json = try #require(String(bytes: data, encoding: .utf8)) + #expect(json.contains("conditional")) + #expect(json.contains(conditional.id.uuidString)) + } + + @Test + func `flattened tokens include conditional branches`() { + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let layout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + let flattened = layout.flattenedTokens(conditionals: [conditional]) + + #expect(flattened.contains(.icon)) + #expect(flattened.contains(.conditional(id: conditional.id))) + #expect(flattened.contains(.percent(window: .session))) + #expect(flattened.contains(.resetCountdown)) + } + + @Test + func `conditional normalization clamps thresholds and clause count`() { + let predicate = MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0) + let manyClauses = (0..<6).map { index in + MenuBarConditionalClause( + combinator: index == 0 ? .or : .and, + predicate: MenuBarConditionalPredicate( + metric: .session, + comparison: .greaterThan, + threshold: 250)) + } + let conditional = MenuBarLayoutConditional( + clauses: manyClauses, + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + + #expect(conditional.clauses.count == 4) + #expect(conditional.clauses.allSatisfy { $0.predicate.threshold == 100 }) + + let empty = MenuBarLayoutConditional( + clauses: [], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(empty.clauses.count == 1) + #expect(empty.clauses[0].combinator == nil) + #expect(empty.clauses[0].predicate.metric == .session) + #expect(empty.clauses[0].predicate.comparison == .greaterThan) + #expect(empty.clauses[0].predicate.threshold == 0) + + let forcedFirst = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: .or, + predicate: MenuBarConditionalPredicate( + metric: .weekly, + comparison: .greaterThanOrEqual, + threshold: 5))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(forcedFirst.clauses[0].combinator == nil) + } + + @Test + @MainActor + func `conditional display name falls back when unnamed then uses its name`() { + let unnamed = MenuBarLayoutConditional.makeDefault() + #expect(!unnamed.displayName.isEmpty) + + let named = MenuBarLayoutConditional( + name: "Gate", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .automatic), + elseToken: .hidden) + #expect(named.displayName == "Gate") + } + + @Test + func `conditional name survives codable round trip and legacy form decodes unnamed`() throws { + let named = MenuBarLayoutConditional( + name: " Zeroth Gate ", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .automatic), + elseToken: .hidden) + + let data = try JSONEncoder().encode(named) + let decoded = try JSONDecoder().decode(MenuBarLayoutConditional.self, from: data) + #expect(decoded == named) + #expect(decoded.name == " Zeroth Gate ") + + // An older persisted conditional without a `name` key must decode with an empty name. + guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + Issue.record("expected a JSON object") + return + } + json.removeValue(forKey: "name") + let legacyData = try JSONSerialization.data(withJSONObject: json) + let legacy = try JSONDecoder().decode(MenuBarLayoutConditional.self, from: legacyData) + #expect(legacy.name.isEmpty) + } + + @Test + @MainActor + func `conditionals library and layout persist across reload`() { + let suite = "MenuBarLayoutTests-conditional-persistence" + let settings = testSettingsStore(suiteName: suite) + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let defaultEntry = MenuBarLayoutConditional.makeDefault() + let layout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + settings.menuBarLayoutConditionals = [conditional, defaultEntry] + settings.setMenuBarLayout(layout, for: nil) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals == [conditional, defaultEntry]) + #expect(reloaded.menuBarLayout == layout) + } + + @Test + func `predicate without direction decodes as used`() throws { + let predicate = MenuBarConditionalPredicate( + metric: .session, + direction: .remaining, + comparison: .lessThan, + threshold: 20) + let data = try JSONEncoder().encode(predicate) + #expect(try JSONDecoder().decode(MenuBarConditionalPredicate.self, from: data) == predicate) + + // A predicate persisted before `direction` existed compared used percentages. + guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + Issue.record("expected a JSON object") + return + } + json.removeValue(forKey: "direction") + let legacyData = try JSONSerialization.data(withJSONObject: json) + let legacy = try JSONDecoder().decode(MenuBarConditionalPredicate.self, from: legacyData) + #expect(legacy.direction == .used) + #expect(legacy.metric == .session) + #expect(legacy.threshold == 20) + } + + @Test + func `threshold clamps to the metric unit range`() { + let clamped = { (metric: MenuBarConditionalMetric, threshold: Double) -> Double in + MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: metric, + comparison: .lessThan, + threshold: threshold))], + thenToken: .hidden, + elseToken: .hidden).clauses[0].predicate.threshold + } + #expect(clamped(.sessionResetsIn, 9000) == 8760) + #expect(clamped(.sessionResetsIn, 2.5) == 2.5) + #expect(clamped(.weeklyPace, -250) == -100) + #expect(clamped(.costToday, -5) == 0) + #expect(clamped(.session, 250) == 100) + } + + @Test + func `direction is dropped for metrics without a complement`() { + let predicate = MenuBarConditionalPredicate( + metric: .costToday, + direction: .remaining, + comparison: .greaterThan, + threshold: 1) + #expect(predicate.normalized().direction == .used) + + let kept = MenuBarConditionalPredicate( + metric: .balance, + direction: .remaining, + comparison: .greaterThan, + threshold: 1) + #expect(kept.normalized().direction == .remaining) + } + + @Test + func `referenced conditional predicates include nested branches`() { + let inner = MenuBarLayoutConditional( + name: "inner", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .costToday, + comparison: .greaterThan, + threshold: 1))], + thenToken: .costToday, + elseToken: .hidden) + let outer = MenuBarLayoutConditional( + name: "outer", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .sessionResetsIn, + comparison: .lessThan, + threshold: 2))], + thenToken: .conditional(id: inner.id), + elseToken: .hidden) + let layout = MenuBarLayout(lines: [[.icon, .conditional(id: outer.id)]]) + + let metrics = Set(layout + .referencedConditionalPredicates(conditionals: [outer, inner]) + .map(\.metric)) + #expect(metrics == [.sessionResetsIn, .costToday]) + } + + @Test + @MainActor + func `unrecognized conditional metric drops only its own entry`() throws { + let suite = "MenuBarLayoutTests-conditional-unknown-metric" + let settings = testSettingsStore(suiteName: suite) + let valid = MenuBarLayoutConditional( + name: "valid", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .session, + comparison: .greaterThan, + threshold: 30))], + thenToken: .percent(window: .session), + elseToken: .hidden) + let future = MenuBarLayoutConditional( + name: "future", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .weekly, + comparison: .greaterThan, + threshold: 40))], + thenToken: .percent(window: .weekly), + elseToken: .hidden) + + // Rewrite the second entry's metric to a raw value this build has no case for, the way a newer + // release would once the metric set grows again. + let encoded = try JSONEncoder().encode([valid, future]) + guard var blob = try JSONSerialization.jsonObject(with: encoded) as? [[String: Any]], + var clauses = blob[1]["clauses"] as? [[String: Any]], + var predicate = clauses[0]["predicate"] as? [String: Any] + else { + Issue.record("expected an array of conditional objects") + return + } + predicate["metric"] = "notAMetric" + clauses[0]["predicate"] = predicate + blob[1]["clauses"] = clauses + try settings.userDefaults.set( + JSONSerialization.data(withJSONObject: blob), + forKey: "menuBarLayoutConditionals") + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals == [valid]) + } + + @Test + @MainActor + func `a fresh install ships an editable conditionals library`() { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-shipped-conditionals") + + let shipped = MenuBarLayoutConditional.shippedLibrary() + #expect(!shipped.isEmpty) + #expect(settings.menuBarLayoutConditionals == shipped) + + // Identities are fixed, so a placed reference keeps resolving on the next launch. + #expect(MenuBarLayoutConditional.shippedLibrary().map(\.id) == shipped.map(\.id)) + + // Each entry has to be a usable, distinctly named starting point. + #expect(Set(shipped.map(\.id)).count == shipped.count) + #expect(Set(shipped.map { $0.name.lowercased() }).count == shipped.count) + #expect(shipped.allSatisfy { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + #expect(shipped.allSatisfy { !$0.clauses.isEmpty && $0.clauses[0].combinator == nil }) + + // The automatic default must keep exercising the remaining direction: it is the only shipped + // entry proving a non-`used` reading survives a fresh install. + let remainingDefaults = shipped.filter { entry in + entry.clauses.contains { $0.predicate.direction == .remaining } + } + #expect(remainingDefaults.count == 1) + #expect(remainingDefaults.first?.clauses.first?.predicate.metric == .automatic) + #expect(remainingDefaults.first?.thenToken == .percent(window: .automatic)) + #expect(remainingDefaults.first?.elseToken == .resetCountdown) + } + + @Test + @MainActor + func `clearing the shipped conditionals library survives a reload`() { + let suite = "MenuBarLayoutTests-shipped-conditionals-cleared" + let settings = testSettingsStore(suiteName: suite) + #expect(!settings.menuBarLayoutConditionals.isEmpty) + + // Removing every shipped entry is a deliberate choice; the next launch must not reseed. + for conditional in settings.menuBarLayoutConditionals { + settings.removeMenuBarLayoutConditional(id: conditional.id) + } + #expect(settings.menuBarLayoutConditionals.isEmpty) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals.isEmpty) + } + + @Test + func `unique copy name walks numbered suffixes`() { + let existing: Set = ["gate (copy)"] + #expect(MenuBarLayoutConditional.uniqueCopyName(basedOn: "Gate", existingNames: existing) == "Gate (copy 2)") + + // An empty stem falls back to the generic conditional label rather than a bare suffix. + let generic = MenuBarLayoutConditional.uniqueCopyName(basedOn: " ", existingNames: []) + #expect(!generic.isEmpty) + } + + @Test + func `conditional summary parenthesizes mixed combinators`() { + let mixed = MenuBarLayoutConditional( + clauses: [ + MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0)), + MenuBarConditionalClause( + combinator: .or, + predicate: MenuBarConditionalPredicate(metric: .weekly, comparison: .greaterThan, threshold: 50)), + MenuBarConditionalClause( + combinator: .and, + predicate: MenuBarConditionalPredicate( + metric: .automatic, + comparison: .greaterThan, + threshold: 80)), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(mixed.editorSummary(provider: nil).contains("(")) + + let uniform = MenuBarLayoutConditional( + clauses: [ + MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0)), + MenuBarConditionalClause( + combinator: .and, + predicate: MenuBarConditionalPredicate(metric: .weekly, comparison: .greaterThan, threshold: 50)), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(!uniform.editorSummary(provider: nil).contains("(")) + } + + /// The conditional editor row is driven entirely by these three metric properties, so pinning them + /// pins which controls appear: the direction picker is shown only when `supportsDirection`, and the + /// label beside the threshold field is `thresholdUnit`. + @Test + func `metric drives the editor row controls and units`() { + #expect(MenuBarConditionalMetric.allCases.count == 18) + + let withDirection = MenuBarConditionalMetric.allCases.filter(\.supportsDirection) + #expect(withDirection == [ + .session, .weekly, .scopedWeekly, .automatic, + .primaryLane, .secondaryLane, .tertiaryLane, .balance, + ]) + + #expect(MenuBarConditionalMetric.session.thresholdUnit == "%") + #expect(MenuBarConditionalMetric.weeklyPace.thresholdUnit == "%") + #expect(MenuBarConditionalMetric.sessionResetsIn.thresholdUnit == "h") + #expect(MenuBarConditionalMetric.runsOutIn.thresholdUnit == "h") + #expect(MenuBarConditionalMetric.costToday.thresholdUnit == "USD") + #expect(MenuBarConditionalMetric.balance.thresholdUnit == "USD") + + #expect(MenuBarConditionalMetric.sessionResetsIn.thresholdStep == 0.5) + #expect(MenuBarConditionalMetric.session.thresholdStep == 1) + + // Every metric needs a label; an empty one would render a blank picker row. + for metric in MenuBarConditionalMetric.allCases { + #expect(!metric.editorLabel(provider: nil).isEmpty, "\(metric.rawValue)") + } + } + + @Test + func `summary spells out direction and unit for a mixed-unit condition`() { + let conditional = MenuBarLayoutConditional( + name: "Session busy and about to reset", + clauses: [ + MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .session, + direction: .used, + comparison: .greaterThan, + threshold: 50)), + MenuBarConditionalClause( + combinator: .and, + predicate: MenuBarConditionalPredicate( + metric: .sessionResetsIn, + comparison: .lessThan, + threshold: 2)), + ], + thenToken: .resetCountdown, + elseToken: .hidden) + + let summary = conditional.editorSummary(provider: nil) + #expect(summary == "If Session % used > 50% and Session resets in < 2h then Resets in else Hide") + + // A half-hour threshold keeps its decimal rather than rounding away to "0h". + let halfHour = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .automaticResetsIn, + comparison: .lessThanOrEqual, + threshold: 0.5))], + thenToken: .resetCountdown, + elseToken: .hidden) + #expect(halfHour.editorSummary(provider: nil).contains("<= 0.5h")) + + // Currency thresholds read with a separated unit; percent and hours stay tight against the number. + let credit = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .balance, + direction: .remaining, + comparison: .greaterThanOrEqual, + threshold: 5))], + thenToken: .balance, + elseToken: .hidden) + #expect(credit.editorSummary(provider: nil).contains("Balance remaining >= 5 USD")) + } + + @Test + @MainActor + func `removing a library conditional strips references everywhere`() { + let suite = "MenuBarLayoutTests-removing-conditional" + let settings = testSettingsStore(suiteName: suite) + let conditional = MenuBarLayoutConditional( + id: UUID(), + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let global = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + let override = MenuBarLayout(lines: [[.conditional(id: conditional.id), .percent(window: .weekly)]]) + + settings.menuBarLayoutConditionals = [conditional] + settings.setMenuBarLayout(global, for: nil) + settings.setMenuBarLayout(override, for: .claude) + + settings.removeMenuBarLayoutConditional(id: conditional.id) + + #expect(settings.menuBarLayoutConditionals.isEmpty) + #expect(Self.hasNoConditionalReference(settings.menuBarLayout)) + #expect(Self.hasNoConditionalReference(settings.menuBarLayout(for: .claude))) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals.isEmpty) + #expect(Self.hasNoConditionalReference(reloaded.menuBarLayout)) + #expect(Self.hasNoConditionalReference(reloaded.menuBarLayout(for: .claude))) + } + + private static func hasNoConditionalReference(_ layout: MenuBarLayout) -> Bool { + !layout.lines.flatMap(\.self).contains { token in + if case .conditional = token { return true } + return false + } + } + @Test func `decoding normalizes empty and extra lines`() throws { let emptyData = try JSONEncoder().encode(UnnormalizedLayout(lines: [])) @@ -129,6 +618,42 @@ struct MenuBarLayoutTests { #expect(layout.legacyCompatible().selectedLanes.isEmpty) } + @Test + func `conditional and hidden tokens drop out of the older-readable projection`() { + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .session), + elseToken: .hidden) + + // A 0.53.x decoder has no case for these tokens, so the projection must not contain them; + // the surrounding arrangement has to survive. + let mixed = MenuBarLayout(lines: [[ + .icon, + .conditional(id: conditional.id), + .percent(window: .session), + .hidden, + ]]) + #expect(mixed.legacyCompatible() == MenuBarLayout(lines: [[.icon, .percent(window: .session)]])) + + // A line emptied purely by filtering must not survive as a blank stacked row. + let stacked = MenuBarLayout(lines: [ + [.icon, .percent(window: .weekly)], + [.conditional(id: conditional.id)], + ]) + #expect(stacked.legacyCompatible() == MenuBarLayout(lines: [[.icon, .percent(window: .weekly)]])) + + // Nothing left to project falls back to the default layout rather than an empty title. + let conditionalOnly = MenuBarLayout(lines: [[.conditional(id: conditional.id)]]) + #expect(conditionalOnly.legacyCompatible() == .defaultLayout) + + // A line the user left empty (line break added, no token dropped in yet) is not the same as + // one emptied by filtering, so it survives the projection unchanged. + let pendingSecondLine = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)], []]) + #expect(pendingSecondLine.legacyCompatible() == MenuBarLayout(lines: [[.icon], []])) + } + @Test func `legacy layout JSON without lanePercent cannot decode current lane tokens`() throws { let current = try JSONEncoder().encode(MenuBarLayout(lines: [[ @@ -486,6 +1011,51 @@ struct MenuBarLayoutTests { #expect(reloaded.menuBarLayoutOverrides[.claude] == claude) } + @Test + @MainActor + func `conditional layouts dual-write an older-readable fallback`() throws { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-downgrade") + let conditional = MenuBarLayoutConditional( + name: "Gate", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 50))], + thenToken: .percent(window: .session), + elseToken: .hidden) + let global = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id), .percent(window: .automatic)]]) + let cursor = MenuBarLayout(lines: [[.conditional(id: conditional.id), .percent(window: .weekly)]]) + + settings.menuBarLayoutConditionals = [conditional] + settings.setMenuBarLayout(global, for: nil) + settings.setMenuBarLayout(cursor, for: .cursor) + + let decoder = JSONDecoder() + let currentGlobal = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.layoutCurrent)) + let legacyGlobal = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.layout)) + let currentOverrides = try #require( + settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.overridesCurrent)) + let legacyOverrides = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.overrides)) + + // The legacy blob must stay decodable by the 0.53.x token surface, minus the new tokens. + #expect(try decoder.decode(PreLanePercentMenuBarLayout.self, from: legacyGlobal) == PreLanePercentMenuBarLayout( + lines: [[.icon, .percent(window: .automatic)]])) + #expect(throws: DecodingError.self) { + try decoder.decode(PreLanePercentMenuBarLayout.self, from: currentGlobal) + } + + let legacyMap = try decoder.decode([String: PreLanePercentMenuBarLayout].self, from: legacyOverrides) + #expect(legacyMap["cursor"] == PreLanePercentMenuBarLayout(lines: [[.percent(window: .weekly)]])) + #expect(throws: DecodingError.self) { + try decoder.decode([String: PreLanePercentMenuBarLayout].self, from: currentOverrides) + } + + // Upgrading again keeps the full-fidelity layout: the dual-write blobs agree. + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayout == global) + #expect(reloaded.menuBarLayoutOverrides[.cursor] == cursor) + #expect(reloaded.menuBarLayoutConditionals == [conditional]) + } + @Test @MainActor func `Kimi lane overrides dual-write reversed semantic windows`() throws { @@ -620,6 +1190,147 @@ struct MenuBarLayoutTests { #expect(reloaded.menuBarLayout == current) } + @Test + @MainActor + func `conditional library dual-writes an older-readable projection`() throws { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-downgrade") + let readable = MenuBarLayoutConditional( + name: "readable", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .session, + comparison: .greaterThan, + threshold: 50))], + thenToken: .percent(window: .session), + elseToken: .hidden) + let newMetric = MenuBarLayoutConditional( + name: "new metric", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .sessionResetsIn, + comparison: .lessThan, + threshold: 2))], + thenToken: .resetCountdown, + elseToken: .hidden) + // An older release ignores the unknown `direction` key, so this would come back inverted rather + // than absent — worse than dropping it. + let inverted = MenuBarLayoutConditional( + name: "inverted", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .weekly, + direction: .remaining, + comparison: .greaterThan, + threshold: 20))], + thenToken: .percent(window: .weekly), + elseToken: .hidden) + settings.menuBarLayoutConditionals = [readable, newMetric, inverted] + + let decoder = JSONDecoder() + let current = try #require( + settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent)) + let legacy = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionals)) + + #expect(try decoder.decode([MenuBarLayoutConditional].self, from: current) == + [readable, newMetric, inverted]) + + // The whole point: a legacy decoder reads the projection, and would have thrown on the full blob. + let legacyEntries = try decoder.decode([PreExpandedConditional].self, from: legacy) + #expect(legacyEntries.map(\.name) == ["readable"]) + #expect(legacyEntries.first?.clauses.first?.predicate.metric == .session) + #expect(throws: DecodingError.self) { + try decoder.decode([PreExpandedConditional].self, from: current) + } + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals == [readable, newMetric, inverted]) + } + + @Test + @MainActor + func `conditional library load prefers a legacy blob edited by an older release`() throws { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-downgrade-edit") + let current = MenuBarLayoutConditional( + name: "current", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .runsOutIn, + comparison: .lessThan, + threshold: 6))], + thenToken: .runsOut, + elseToken: .hidden) + settings.menuBarLayoutConditionals = [current] + + // An older release rewrote the shared key with its own edit; that must win over our projection. + let edited = MenuBarLayoutConditional( + name: "edited by older release", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .weekly, + comparison: .greaterThan, + threshold: 75))], + thenToken: .percent(window: .weekly), + elseToken: .hidden) + try settings.userDefaults.set( + JSONEncoder().encode([edited]), + forKey: MenuBarLayoutUserDefaultsKey.conditionals) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals == [edited]) + } + + @Test + @MainActor + func `conditional library load keeps new metrics when the legacy blob is its own projection`() { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-legacy-echo") + let newMetric = MenuBarLayoutConditional( + name: "new metric", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .costToday, + comparison: .greaterThan, + threshold: 1))], + thenToken: .costToday, + elseToken: .hidden) + settings.menuBarLayoutConditionals = [newMetric] + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals == [newMetric]) + } + + @Test + @MainActor + func `startup materializes a missing conditional projection`() throws { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-startup-dual-write") + // A pre-upgrade install only has the legacy key. + let existing = MenuBarLayoutConditional( + name: "existing", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: .automatic, + comparison: .greaterThan, + threshold: 40))], + thenToken: .percent(window: .automatic), + elseToken: .hidden) + settings.userDefaults.removeObject(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent) + try settings.userDefaults.set( + JSONEncoder().encode([existing]), + forKey: MenuBarLayoutUserDefaultsKey.conditionals) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals == [existing]) + let materialized = try #require( + reloaded.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent)) + #expect(try JSONDecoder().decode([MenuBarLayoutConditional].self, from: materialized) == [existing]) + } + @Test @MainActor func `lane override load prefers a legacy dictionary edited by an older release`() throws { @@ -743,3 +1454,30 @@ private enum PreLanePercentMenuBarLayoutToken: Codable, Equatable { private struct PreLanePercentMenuBarLayout: Codable, Equatable { let lines: [[PreLanePercentMenuBarLayoutToken]] } + +/// The legacy conditional surface: four percent metrics, no `direction`. Its synthesized `Codable` +/// throws on any other metric raw value and silently ignores unknown keys, which is exactly why the +/// older-readable projection has to drop those entries rather than hand them over. +private enum PreExpandedConditionalMetric: String, Codable, Equatable { + case session + case weekly + case scopedWeekly + case automatic +} + +private struct PreExpandedConditionalPredicate: Codable, Equatable { + let metric: PreExpandedConditionalMetric + let comparison: String + let threshold: Double +} + +private struct PreExpandedConditionalClause: Codable, Equatable { + let combinator: String? + let predicate: PreExpandedConditionalPredicate +} + +private struct PreExpandedConditional: Codable, Equatable { + let id: UUID + let name: String + let clauses: [PreExpandedConditionalClause] +} diff --git a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift index eec9a75fb..5d3e83d9b 100644 --- a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift +++ b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift @@ -47,6 +47,7 @@ struct MenuCardClaudeSwapAccountTests { tokenSnapshot: nil, tokenError: nil, account: AccountInfo(email: account.displayLabel, plan: nil), + accountIsAuthoritative: true, planOverride: planOverride, isRefreshing: false, lastError: account.error, @@ -54,6 +55,7 @@ struct MenuCardClaudeSwapAccountTests { resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: false, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel, hidePersonalInfo: hidePersonalInfo, now: now)) } @@ -110,4 +112,107 @@ struct MenuCardClaudeSwapAccountTests { #expect(!model.email.contains("personal@example.com")) #expect(!model.email.contains("example.com")) } + + @Test + func `claude swap cards that share an email include the organization name`() throws { + let now = Date(timeIntervalSince1970: 1_782_000_000) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "shared@example.com", + organizationName: "Sendbird", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 10, resetsAt: now), + sevenDay: nil), + ClaudeSwapAccountRow( + number: 2, + email: "shared@example.com", + organizationName: "Acme", + isActive: false, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 20, resetsAt: now), + sevenDay: nil), + ]) + let models = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).map { account in + UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: account.snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: account.displayLabel, plan: nil), + accountIsAuthoritative: true, + isRefreshing: false, + lastError: account.error, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel, + hidePersonalInfo: false, + now: now)) + } + + #expect(models.map(\.email) == [ + "shared@example.com · Sendbird", + "shared@example.com · Acme", + ]) + } + + @Test + func `at limit unavailable card keeps usage bars and names the exhausted window`() throws { + let now = Date(timeIntervalSince1970: 1_782_000_000) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: now.addingTimeInterval(3600)), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: now.addingTimeInterval(86400))), + ]) + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).first) + let snapshot = try #require(account.snapshot) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: account.displayLabel, plan: nil), + isRefreshing: false, + lastError: account.error, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.email == "limited@example.com") + let primary = try #require(model.metrics.first(where: { $0.id == "primary" })) + #expect(primary.percent == 100) + let secondary = try #require(model.metrics.first(where: { $0.id == "secondary" })) + #expect(secondary.percent == 100) + #expect(model.subtitleText == + "Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1d.") + #expect(model.subtitleStyle == .error) + #expect(!model.subtitleText.contains("Usage fetch failed")) + } } diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index 1a0f4b9b6..203bbc120 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -152,6 +152,98 @@ struct MenuCardDeepSeekTests { #expect(details.rows.first { $0.label == "This month" }?.value == "¥0.0456 · 456 tokens") } + @Test + func `model localizes deepseek usage details in simplified chinese`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let details = try #require(model.providerDetails.first) + #expect(details.title == "用量明细") + #expect(details.rows.map(\.label) == [ + "今日", + "本月", + "请求", + "最常用模型", + "缓存命中输入", + "缓存未命中输入", + "输出", + ]) + #expect(details.rows[0].value == "¥0.0123 · 123 token 用量") + #expect(details.rows[1].value == "¥0.0456 · 456 token 用量") + #expect(details.rows[3].value == "deepseek-chat") + #expect(details.chart?.title == "每日 token") + #expect(details.chart?.unit == "token") + } + + @Test + func `model localizes deepseek balance components in simplified chinese`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now) + + let model = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let balance = try #require(model.metrics.first) + #expect(balance.statusText == "$9.32(付费:$9.32 / 赠送:$0.00)") + } + + @Test + func `model localizes deepseek balance fallback messages in simplified chinese`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect( + UsageMenuCardView.Model.localizedDeepSeekBalanceDescription( + "¥0.00 — add credits at platform.deepseek.com") + == "¥0.00 — 请前往 platform.deepseek.com 充值") + #expect( + UsageMenuCardView.Model.localizedDeepSeekBalanceDescription( + "Balance unavailable for API calls") + == "API 调用余额不可用") + } + } + @Test func `model explains unavailable deepseek usage when cost summary is enabled`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 118c55585..ec663e095 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -475,6 +475,42 @@ struct FactoryMenuCardModelTests { #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Monthly"]) } + @Test + func `factory time window labels localize in simplified chinese`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.factory]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .factory, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5 小时", "每周", "每月"]) + } + } + @Test func `factory legacy billing keeps pool labels`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift index ec471b93d..752946569 100644 --- a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -425,6 +425,41 @@ extension StatusMenuTests { #expect(displacedIncoming.first === incoming) } + @Test + func `cached provider content preserves menu item subclasses across switch back`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let plainItem = NSMenuItem(title: "Overview", action: nil, keyEquivalent: "") + let cardItem = controller.makeMenuCardItem(Text("Codex"), id: "codex", width: 300) + let menu = NSMenu() + menu.addItem(plainItem) + + let displacedPlain = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 0, + with: [cardItem]) + + #expect(menu.items.first === cardItem) + #expect(menu.items.first is MenuCardMenuItem) + #expect(displacedPlain.first === plainItem) + + let displacedCard = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 0, + with: displacedPlain) + + #expect(menu.items.first === plainItem) + #expect(!(menu.items.first is MenuCardMenuItem)) + #expect(displacedCard.first === cardItem) + } + @Test func `cached provider content swap preserves both item sets for switch back`() { let settings = self.makeSettings() @@ -470,6 +505,35 @@ extension StatusMenuTests { #expect(displacedIncoming.map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"]) } + @Test + func `cached hosted row swap carries agent keyboard action identifier`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let liveItem = controller.makeMenuCardItem(Text("first"), id: "agent-session", width: 300, onClick: {}) + liveItem.identifier = NSUserInterfaceItemIdentifier("agent-session-action:first") + let cachedItem = controller.makeMenuCardItem(Text("second"), id: "agent-session", width: 300, onClick: {}) + cachedItem.identifier = NSUserInterfaceItemIdentifier("agent-session-action:second") + let menu = NSMenu() + menu.addItem(liveItem) + + let displaced = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 0, + with: [cachedItem]) + + #expect(menu.items.first === liveItem) + #expect(liveItem.identifier?.rawValue == "agent-session-action:second") + #expect(displaced.first?.identifier?.rawValue == "agent-session-action:first") + #expect(liveItem.representedObject as? String == "agent-session") + } + @Test func `reconcile preserves highlight on a retained custom action row`() { let settings = self.makeSettings() diff --git a/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift index 57cfc5053..f1311c964 100644 --- a/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift @@ -117,6 +117,7 @@ final class MenuLayoutScreenshotRenderTests: XCTestCase { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "proof", isDebugApp: false, now: Self.now)) diff --git a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift index 52c822b7d..b3c4415b9 100644 --- a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift @@ -46,7 +46,7 @@ struct OpenCodeGoProviderStrategyTests { let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext()) - #expect(strategies.map(\.id) == ["opencodego.local", "opencodego.web"]) + #expect(strategies.map(\.id) == ["opencodego.local", "opencodego.api", "opencodego.web"]) } @Test @@ -55,7 +55,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(selectedTokenAccountID: UUID())) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -68,7 +68,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(settings: settings)) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -81,7 +81,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(settings: settings)) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -90,7 +90,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(env: ["CODEXBAR_OPENCODEGO_WORKSPACE_ID": "wrk_env"])) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -105,8 +105,8 @@ struct OpenCodeGoProviderStrategyTests { let environmentStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(env: ["CODEXBAR_OPENCODEGO_WORKSPACE_ID": " \t "])) - #expect(settingsStrategies.map(\.id) == ["opencodego.local", "opencodego.web"]) - #expect(environmentStrategies.map(\.id) == ["opencodego.local", "opencodego.web"]) + #expect(settingsStrategies.map(\.id) == ["opencodego.local", "opencodego.api", "opencodego.web"]) + #expect(environmentStrategies.map(\.id) == ["opencodego.local", "opencodego.api", "opencodego.web"]) } @Test @@ -117,6 +117,14 @@ struct OpenCodeGoProviderStrategyTests { #expect(strategies.map(\.id) == ["opencodego.web"]) } + @Test + func `api source uses only the public usage endpoint`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext(sourceMode: .api)) + + #expect(strategies.map(\.id) == ["opencodego.api"]) + } + @Test func `local strategy falls through to web when local history is unavailable`() { let strategy = OpenCodeGoLocalUsageFetchStrategy() diff --git a/Tests/CodexBarTests/OpenCodeGoSettingsReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoSettingsReaderTests.swift new file mode 100644 index 000000000..c12c5a070 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoSettingsReaderTests.swift @@ -0,0 +1,19 @@ +import CodexBarCore +import Testing + +struct OpenCodeGoSettingsReaderTests { + @Test + func `reads and normalizes API key`() { + #expect(OpenCodeGoSettingsReader.apiKey(environment: ["OPENCODE_API_KEY": " go_test "]) == "go_test") + #expect(OpenCodeGoSettingsReader.apiKey(environment: ["OPENCODE_API_KEY": "'go_quoted'"]) == "go_quoted") + #expect(OpenCodeGoSettingsReader.apiKey(environment: ["OPENCODE_API_KEY": " "]) == nil) + } + + @Test + func `descriptor exposes API source and config override`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .opencodego) + + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api, .web]) + #expect(descriptor.credentials?.supportsAPIKeyOverride == true) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index fa35f750e..7e9258f99 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -43,6 +43,70 @@ private final class OpenCodeGoContinuationBox: @unchecked Senda @Suite(.serialized) struct OpenCodeGoUsageFetcherErrorTests { + @Test + func `public usage API sends bearer token and parses all windows`() async throws { + defer { OpenCodeGoStubURLProtocol.handler = nil } + let requests = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + requests.append(request) + let body = """ + { + "usage": { + "rolling": {"percent": 12, "resetsAt": "2026-08-12T02:00:00.000Z"}, + "weekly": {"percent": 8, "resetsAt": "2026-08-18T00:00:00.000Z"}, + "monthly": {"percent": 35, "resetsAt": "2026-09-01T00:00:00.000Z"} + } + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json") + } + + let now = Date(timeIntervalSince1970: 1_786_493_600) + let snapshot = try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: "go_secret", + timeout: 2, + now: now, + session: self.makeSession()) + + #expect(requests.values.count == 1) + #expect(requests.values.first?.url?.path == "/zen/go/v1/usage") + #expect(requests.values.first?.value(forHTTPHeaderField: "Authorization") == "Bearer go_secret") + #expect(snapshot.rollingUsagePercent == 12) + #expect(snapshot.weeklyUsagePercent == 8) + #expect(snapshot.monthlyUsagePercent == 35) + #expect(snapshot.hasWeeklyUsage) + #expect(snapshot.hasMonthlyUsage) + } + + @Test + func `public usage API maps unauthorized response to invalid credentials`() async { + defer { OpenCodeGoStubURLProtocol.handler = nil } + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse( + url: url, + body: #"{"error":"unauthorized"}"#, + statusCode: 401, + contentType: "application/json") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: "bad", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected invalidCredentials") + } catch let error as OpenCodeGoUsageError { + guard case .invalidCredentials = error else { + Issue.record("Expected invalidCredentials, got \(error)") + return + } + } catch { + Issue.record("Expected OpenCodeGoUsageError, got \(error)") + } + } + @Test func `dashboard URL uses normalized workspace ID`() { #expect( diff --git a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift index 2b7025291..113420d7f 100644 --- a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift @@ -83,6 +83,7 @@ struct OpenCodeGoWebOverlayTests { private func makeContext( includeOptionalUsage: Bool = true, settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:], selectedTokenAccountID: UUID? = nil) -> ProviderFetchContext { ProviderFetchContext( @@ -93,9 +94,9 @@ struct OpenCodeGoWebOverlayTests { webTimeout: 1, webDebugDumpHTML: false, verbose: false, - env: [:], + env: env, settings: settings, - fetcher: UsageFetcher(environment: [:]), + fetcher: UsageFetcher(environment: env), claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), selectedTokenAccountID: selectedTokenAccountID) @@ -246,6 +247,43 @@ struct OpenCodeGoWebOverlayTests { } } + @Test + func `local strategy prefers API windows while preserving local history and web balance`() async throws { + let observedKeys = Recorder() + let webCalls = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + webCalls.append(cookieHeader) + return Self.webUsage(zenBalanceUSD: 42.5) + }, + apiUsageOverlayFetcher: { _, apiKey in + observedKeys.append(apiKey) + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 11, + weeklyUsagePercent: 22, + monthlyUsagePercent: 33, + rollingResetInSec: 18100, + weeklyResetInSec: 266_500, + monthlyResetInSec: 1_539_100, + updatedAt: Self.updatedAt.addingTimeInterval(3)) + }) + + let result = try await strategy.fetch(self.makeContext( + settings: self.makeManualCookieSettings(), + env: [OpenCodeGoSettingsReader.apiKeyEnvironmentKey: "go_test"])) + + #expect(result.sourceLabel == "local+api") + #expect(observedKeys.values == ["go_test"]) + #expect(webCalls.values == ["auth=test"]) + #expect(result.usage.primary?.usedPercent == 11) + #expect(result.usage.secondary?.usedPercent == 22) + #expect(result.usage.tertiary?.usedPercent == 33) + #expect(result.usage.opencodegoUsage?.daily.count == 1) + #expect(result.usage.providerCost?.used == 42.5) + } + @Test func `local strategy keeps local estimate when web overlay is unavailable`() async throws { let strategy = OpenCodeGoLocalUsageFetchStrategy( diff --git a/Tests/CodexBarTests/PopupLocalizationTests.swift b/Tests/CodexBarTests/PopupLocalizationTests.swift index 97b46c327..b7d7d04db 100644 --- a/Tests/CodexBarTests/PopupLocalizationTests.swift +++ b/Tests/CodexBarTests/PopupLocalizationTests.swift @@ -7,6 +7,62 @@ import Testing @MainActor @Suite(.serialized) struct PopupLocalizationTests { + @Test + func `simplified Chinese derives session quota titles from their duration`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + for (windowMinutes, expectedTitle) in [(60, "1 小时"), (300, "5 小时"), (720, "12 小时")] { + let model = try Self.makeClaudeMenuCardModel(primaryWindowMinutes: windowMinutes) + + #expect(model.metrics.first?.title == expectedTitle) + } + } + } + + @Test + func `simplified Chinese labels a Claude weekly primary fallback accurately`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + let model = try Self.makeClaudeMenuCardModel(primaryWindowMinutes: 7 * 24 * 60) + + #expect(model.metrics.first?.title == "每周") + } + } + + @Test + func `simplified Chinese history selector uses quota duration without changing conversations`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let histories = [ + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [PlanUtilizationHistoryEntry(capturedAt: now, usedPercent: 10, resetsAt: nil)]), + PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 7 * 24 * 60, + entries: [PlanUtilizationHistoryEntry(capturedAt: now, usedPercent: 20, resetsAt: nil)]), + ] + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + updatedAt: now) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .claude, + snapshot: snapshot) + + #expect(model.visibleSeriesTitles == ["5 小时", "每周"]) + #expect(String(format: L("Session %@"), "abc123") == "会话 abc123") + } + } + @Test func `descriptor account labels use selected localization`() throws { try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { @@ -46,6 +102,45 @@ struct PopupLocalizationTests { } } + @Test + func `factory descriptor localizes every time window label`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + let suite = "PopupLocalizationTests-factory-rate-windows" + let settings = try Self.makeSettingsStore(suite: suite) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: nil), + provider: .factory) + + let descriptor = MenuDescriptor.build( + provider: .factory, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = Self.textLines(from: descriptor) + + #expect(lines.contains { $0.hasPrefix("5 小时:") }) + #expect(lines.contains { $0.hasPrefix("每周:") }) + #expect(lines.contains { $0.hasPrefix("每月:") }) + #expect(!lines.contains { $0.hasPrefix("5-hour:") }) + } + } + @Test func `generic provider details keep canonical labels alongside localized core metrics`() throws { try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { @@ -88,10 +183,12 @@ struct PopupLocalizationTests { now: now)) #expect(model.metrics.first?.title == "額度") - let apiKey = try #require(model.providerDetails.first { $0.title == "API key" }) + // After 84a4ca725, generic providers localize section titles and row labels via L(); + // values and chart point labels stay canonical. + let apiKey = try #require(model.providerDetails.first { $0.title == "API 金鑰" }) #expect(apiKey.rows.map(\.label) == [ "API key budget", "API key remaining", "API key used", "Reset window", - "Today", "This week", "This month", "Rate limit", + "今天", "本週", "本月", "Rate limit", ]) #expect(apiKey.chart?.points.map(\.label) == ["Today", "This week", "This month"]) #expect(apiKey.rows.last?.value == "100 requests / 10s") @@ -194,4 +291,36 @@ struct PopupLocalizationTests { return text } } + + private static func makeClaudeMenuCardModel(primaryWindowMinutes: Int) throws -> UsageMenuCardView.Model { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: primaryWindowMinutes, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 33e18e904..657985fa5 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -213,7 +213,7 @@ struct ProviderArchitectureGatekeeperTests { ]) #expect(descriptors.compactMap { descriptor in descriptor.credentials?.apiKeyDebugLabel.map { (descriptor.id, $0) } - }.map(\.0) == [.openai, .azureopenai, .openrouter, .elevenlabs]) + }.map(\.0) == [.openai, .azureopenai, .opencodego, .openrouter, .elevenlabs]) #expect(CodexProviderDescriptor.descriptor.tokenCost.menuHintLines == [.localized("codex_api_estimate_hint")]) #expect(ClaudeProviderDescriptor.descriptor.tokenCost.menuHintLines == [.estimate]) @@ -920,7 +920,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The memory-pressure debug fixture installs its synthetic entry in the Codex cache slot."), SuppressedProviderReference( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1123, + line: 1110, anchor: "controller.refreshOpenMenuIfStillVisible(menu, provider: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -998,13 +998,13 @@ struct ProviderArchitectureGatekeeperTests { reason: "This named provider resolver supplies its fixed provider identity to the shared presentation helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+HistoricalPace.swift", - line: 74, + line: 153, anchor: "let ownership = self.codexOwnershipContext(preferredEmail: snapshot.accountEmail(for: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+HistoricalPace.swift", - line: 133, + line: 212, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1016,37 +1016,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 440, + line: 442, anchor: "usage: result.usage.scoped(to: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 464, + line: 467, anchor: "usage: result.usage.scoped(to: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 951, - anchor: "let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)),", - expectedProviderIDs: ["codex"], - reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 959, - anchor: "let identity = snapshot.identity(for: .codex)", - expectedProviderIDs: ["codex"], - reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 961, + line: 932, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1499, + line: 1475, anchor: "let currentAccount = self.uniqueTokenAccount(provider: .claude, accountID: fetchedAccount.id),", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1094,31 +1082,31 @@ struct ProviderArchitectureGatekeeperTests { reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 285, + line: 299, anchor: "return self.tokenAccountSnapshotCacheKey(provider: .claude, account: account)", expectedProviderIDs: ["claude"], reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 301, - anchor: "provider: .claude,", + line: 306, + anchor: "return self.tokenAccountSnapshotCacheKey(provider: .claude, account: account)", expectedProviderIDs: ["claude"], reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1050, + line: 1055, anchor: "provider: .deepseek,", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1152, + line: 1157, anchor: "let sourceMode = self.sourceMode(for: .claude)", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1156, + line: 1161, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1280,7 +1268,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This tagged diagnostic payload encodes MiniMax details under the matching wire key."), SuppressedProviderReference( path: "Sources/CodexBarCore/UsageFetcher.swift", - line: 1803, + line: 1807, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1460,97 +1448,109 @@ struct ProviderArchitectureGatekeeperTests { reason: "This WidgetKit default or preview pins the established Codex sample provider."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 362, + line: 476, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 364, + line: 478, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 434, + line: 402, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 436, + line: 404, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 578, + line: 529, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 607, + line: 572, anchor: "let providerName = store.metadata(for: .codex).displayName", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), + SuppressedProviderReference( + path: "Sources/CodexBar/SpendDashboardController.swift", + line: 1475, + anchor: "provider: .codex,", + expectedProviderIDs: ["codex"], + reason: "This OpenCodex enrichment descriptor maps the canonical source back to the Codex family."), + SuppressedProviderReference( + path: "Sources/CodexBar/SpendDashboardController.swift", + line: 1504, + anchor: "if providerID == UsageProvider.codex.rawValue {", + expectedProviderIDs: ["codex"], + reason: "This publication projection expands the fixed Codex provider family into its account sources."), + SuppressedProviderReference( + path: "Sources/CodexBar/SpendDashboardController.swift", + line: 1521, + anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", + expectedProviderIDs: ["codex"], + reason: "This publication projection maps stable Codex account source IDs back to their provider family."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 873, + line: 876, anchor: ".descriptor(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 875, + line: 878, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1359, + line: 1363, anchor: "let scoped = result.usage.scoped(to: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1448, - anchor: "provider: .codex,", - expectedProviderIDs: ["codex"], - reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1464, + line: 1468, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1451, + line: 1455, anchor: "self.handlePredictivePaceWarningTransitions(provider: .codex, snapshot: snapshot)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1461, + line: 1465, anchor: "self.rememberLiveSystemCodexEmailIfNeeded(snapshot.accountEmail(for: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1444, + line: 1448, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1485, + line: 1489, anchor: "self.snapshots.removeValue(forKey: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1521, + line: 1525, anchor: "from: self.presentationSnapshot(for: .deepseek))", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1563,61 +1563,61 @@ struct ProviderArchitectureGatekeeperTests { "double-counting the same logs."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 212, + line: 217, anchor: "let scope = self.tokenCostScope(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 214, + line: 219, anchor: "let publicationRevision = self.providerPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 215, + line: 220, anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 217, + line: 222, anchor: "let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 218, + line: 223, anchor: "let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 248, + line: 253, anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 249, + line: 254, anchor: "self.isEnabled(.codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 258, + line: 263, anchor: "self.installCachedTokenSnapshot(result.snapshot, for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 358, + line: 363, anchor: "let credentialFingerprint = CookieHeaderCache.loadForDisplay(provider: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 373, + line: 378, anchor: "let scope = self.tokenCostScope(for: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1747,7 +1747,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayout.swift", - line: 353, + line: 788, anchor: "ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.primarySemanticWindow)", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -1755,7 +1755,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayoutEditor.swift", - line: 672, + line: 876, anchor: "let provider = self.provider ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -1763,7 +1763,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayoutEditor.swift", - line: 701, + line: 906, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -1811,7 +1811,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 227, + line: 231, anchor: "guard provider == .litellm,", expectedProviderIDs: ["litellm"], expectedReferenceCount: 1, @@ -1819,7 +1819,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 301, + line: 305, anchor: "if input.provider == .kiro {", expectedProviderIDs: ["kilo", "kiro"], expectedReferenceCount: 2, @@ -1827,7 +1827,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 319, + line: 323, anchor: "if input.provider == .mimo, input.snapshot != nil {", expectedProviderIDs: ["claude", "mimo", "opencodego"], expectedReferenceCount: 3, @@ -1835,7 +1835,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 528, + line: 532, anchor: "if input.provider == .factory, snapshot.tertiary != nil {", expectedProviderIDs: [ "alibabatokenplan", "amp", "crof", "cursor", "doubao", "factory", "grok", "opencode", "sub2api", @@ -1857,7 +1857,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 712, + line: 716, anchor: "case .minimax:", expectedProviderIDs: ["codex", "minimax", "poe"], expectedReferenceCount: 3, @@ -1865,7 +1865,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 918, + line: 922, anchor: "if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {", expectedProviderIDs: ["claude", "codex", "copilot"], expectedReferenceCount: 4, @@ -1873,7 +1873,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 943, + line: 947, anchor: "let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil", expectedProviderIDs: ["doubao", "sub2api"], expectedReferenceCount: 3, @@ -1881,7 +1881,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1015, + line: 1010, + anchor: "guard provider == .kiro, namedWindow.id == \"kiro-overage\",", + expectedProviderIDs: ["kiro"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["kiro@0"], + reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), + AllowedProviderConstruct( + path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", + line: 1044, anchor: "if input.provider == .antigravity,", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -1889,7 +1897,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1049, + line: 1078, anchor: "if provider == .claude, window.windowMinutes != 10080 {", expectedProviderIDs: ["antigravity", "claude", "codex"], expectedReferenceCount: 4, @@ -1897,7 +1905,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1081, + line: 1110, anchor: "guard input.provider == .antigravity else { return nil }", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -1929,7 +1937,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1132, + line: 1137, anchor: "if provider == .kiro,", expectedProviderIDs: ["kilo", "kiro"], expectedReferenceCount: 2, @@ -1937,7 +1945,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1155, + line: 1160, anchor: "if provider == .minimax {", expectedProviderIDs: ["codex", "minimax"], expectedReferenceCount: 2, @@ -1945,7 +1953,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1190, + line: 1195, anchor: "guard let loginMethod = snapshot?.loginMethod(for: .kilo) else {", expectedProviderIDs: ["kilo"], expectedReferenceCount: 1, @@ -1991,7 +1999,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1447, + line: 1451, anchor: "var paceDetail = if input.provider == .kimi {", expectedProviderIDs: ["kimi"], expectedReferenceCount: 1, @@ -1999,7 +2007,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1463, + line: 1467, anchor: "if input.provider == .warp,", expectedProviderIDs: ["chutes", "kilo", "kiro", "litellm", "sub2api", "warp"], expectedReferenceCount: 6, @@ -2007,7 +2015,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1496, + line: 1500, anchor: "if input.provider == .alibaba || input.provider == .alibabatokenplan,", expectedProviderIDs: ["alibaba", "alibabatokenplan", "copilot", "crof", "manus", "perplexity", "zenmux"], expectedReferenceCount: 8, @@ -2024,7 +2032,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact renderer preserves Alibaba reset details and QuotaKit provider-specific weekly detail rows."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView.swift", - line: 1537, + line: 1541, anchor: "if input.provider == .synthetic,", expectedProviderIDs: ["synthetic"], expectedReferenceCount: 1, @@ -2181,7 +2189,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 429, + line: 426, anchor: "self.configuration.providerIDs.contains(UsageProvider.codex.rawValue)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2189,7 +2197,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 589, + line: 586, anchor: ".count { $0.provider == .codex }", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2261,7 +2269,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1223, + line: 1245, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, @@ -2286,7 +2294,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 1066, + line: 1070, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, @@ -2382,7 +2390,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+CountdownRefresh.swift", - line: 122, + line: 166, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2406,7 +2414,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1156, + line: 1143, anchor: "return .provider((self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex).instanceID)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2414,7 +2422,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1169, + line: 1156, anchor: "return self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2422,7 +2430,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+MenuBarLayout.swift", - line: 151, + line: 205, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2550,7 +2558,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+HistoricalPace.swift", - line: 127, + line: 206, anchor: "let codexSnapshot = self.snapshots[.codex]", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2694,15 +2702,17 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 345, + line: 346, anchor: "let codexPreparation = provider == .codex ? self.prepareCodexRefreshPublication() : nil", expectedProviderIDs: ["claude", "codex", "kilo"], - expectedReferenceCount: 6, - expectedReferenceFingerprint: ["codex@0", "codex@9", "codex@12", "kilo@16", "kilo@22", "claude@26"], + expectedReferenceCount: 7, + expectedReferenceFingerprint: [ + "codex@0", "codex@1", "codex@10", "codex@13", "kilo@17", "kilo@23", "claude@27", + ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 401, + line: 402, anchor: "let priorClaudeSourceLabel = provider == .claude ? self.lastSourceLabels[.claude] : nil", expectedProviderIDs: ["claude"], expectedReferenceCount: 2, @@ -2710,7 +2720,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 415, + line: 416, anchor: "guard provider == .codex else { return outcome }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2718,7 +2728,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 435, + line: 436, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2726,7 +2736,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 538, + line: 547, anchor: "guard input.provider == .claude else {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2734,7 +2744,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 706, + line: 715, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2742,23 +2752,23 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 740, + line: 749, anchor: "codexOwnerKey: provider == .codex ? context.codexSessionQuotaOwnerKey : nil)", - expectedProviderIDs: ["claude", "codex", "deepseek"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["codex@0", "claude@4", "codex@5", "deepseek@11"], + expectedProviderIDs: ["claude", "codex", "deepseek", "xai"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["codex@0", "claude@4", "codex@5", "deepseek@11", "xai@18"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 772, + line: 787, anchor: "if provider == .gemini {", expectedProviderIDs: ["claude", "codex", "gemini"], - expectedReferenceCount: 5, - expectedReferenceFingerprint: ["gemini@0", "codex@5", "codex@6", "codex@7", "claude@19"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["gemini@0", "codex@5", "claude@17"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 822, + line: 835, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2766,7 +2776,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 846, + line: 859, anchor: "if provider == .codex,", expectedProviderIDs: ["codex", "deepseek"], expectedReferenceCount: 3, @@ -2774,10 +2784,10 @@ struct ProviderArchitectureGatekeeperTests { reason: "Provider-specific failure publication preserves Codex ownership and DeepSeek transition state."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 884, + line: 897, anchor: "guard provider == .deepseek else { return snapshot }", expectedProviderIDs: ["codex", "deepseek"], - expectedReferenceCount: 8, + expectedReferenceCount: 7, expectedReferenceFingerprint: [ "deepseek@0", "deepseek@1", @@ -2786,12 +2796,11 @@ struct ProviderArchitectureGatekeeperTests { "codex@27", "codex@28", "codex@33", - "codex@35", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1011, + line: 975, anchor: "guard provider == .claude, !hasSelectedTokenAccount else { return false }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2799,7 +2808,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1357, + line: 1321, anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", expectedProviderIDs: ["claude", "gemini"], expectedReferenceCount: 2, @@ -2807,7 +2816,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1401, + line: 1365, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2815,7 +2824,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1417, + line: 1381, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 5, @@ -2823,7 +2832,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1500, + line: 1476, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2847,15 +2856,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 193, + line: 196, anchor: "let expectedClaudeQuotaOwnerKey: String? = if provider == .claude {", expectedProviderIDs: ["claude"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["claude@0", "claude@5"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["claude@0", "claude@5", "claude@11"], reason: "This exact widget projection validates preserved Claude usage against the selected owner."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 214, + line: 218, anchor: "(provider == .claude && (storedTokenSnapshot != nil || preservedClaudeUsage != nil))", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2863,7 +2872,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 234, + line: 238, anchor: "if provider == .codex, let snapshot {", expectedProviderIDs: ["claude", "codex", "devin"], expectedReferenceCount: 3, @@ -2871,23 +2880,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 284, + line: 298, anchor: "if let account = self.settings.effectiveSelectedTokenAccount(for: .claude) {", expectedProviderIDs: ["claude"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["claude@0", "claude@7", "claude@8", "claude@10"], - reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), - AllowedProviderConstruct( - path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 313, - anchor: "guard let entry, entry.provider == .claude else { return nil }", - expectedProviderIDs: ["claude"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["claude@0"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["claude@0", "claude@7", "claude@10", "claude@17", "claude@29"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 352, + line: 366, anchor: "let sessionLabel = if provider == .bedrock || provider == .mistral {", expectedProviderIDs: ["bedrock", "codex", "mistral"], expectedReferenceCount: 4, @@ -2895,7 +2896,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 382, + line: 396, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2903,7 +2904,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 401, + line: 415, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2911,7 +2912,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 436, + line: 450, anchor: "let secondaryTitle = if provider == .amp {", expectedProviderIDs: ["alibabatokenplan", "amp", "antigravity", "cursor"], expectedReferenceCount: 5, @@ -2921,7 +2922,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact widget projection preserves QuotaKit's provider-specific secondary-window labels."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 461, + line: 475, anchor: "if provider == .kimi {", expectedProviderIDs: ["claude", "kimi"], expectedReferenceCount: 2, @@ -2929,7 +2930,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact widget projection preserves Kimi balance details and Claude reset-credit inventory."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 606, + line: 611, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2937,7 +2938,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 658, + line: 663, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2945,7 +2946,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 700, + line: 705, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2953,7 +2954,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1024, + line: 1029, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2961,7 +2962,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1047, + line: 1052, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -2969,7 +2970,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1104, + line: 1109, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -2985,7 +2986,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1159, + line: 1164, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3172,7 +3173,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared provider integration dispatches a capability owned by the provider descriptor or adapter."), AllowedProviderConstruct( path: "Sources/CodexBarCore/Providers/ProviderFetchPlan.swift", - line: 200, + line: 219, anchor: "if provider == .kiro {", expectedProviderIDs: ["kiro"], expectedReferenceCount: 1, @@ -3711,7 +3712,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Z.ai team-scope credentials uniquely require an organization or workspace identifier."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 894, + line: 896, anchor: "switch MenuBarMetricPreference(rawValue: migrated[UsageProvider.antigravity.rawValue] ?? \"\") {", expectedProviderIDs: ["antigravity", "cursor"], expectedReferenceCount: 8, @@ -3736,7 +3737,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This curated set is limited to provider feeds whose live components are trusted for native submenu rendering."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 548, + line: 562, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3744,7 +3745,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Cursor's widget primary label follows its persisted request, plan, or API-only rate-window layout."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 562, + line: 576, anchor: "if provider == .grok,", expectedProviderIDs: ["alibabatokenplan", "amp", "crof", "doubao", "grok", "opencode"], expectedReferenceCount: 6, @@ -4084,7 +4085,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 120, + line: 127, anchor: "guard provider == .bedrock, let pc = providerCost else { return nil }", expectedProviderIDs: ["bedrock"], expectedReferenceCount: 1, @@ -4092,7 +4093,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 147, + line: 154, anchor: "guard provider == .moonshot else { return nil }", expectedProviderIDs: ["moonshot"], expectedReferenceCount: 1, @@ -4100,7 +4101,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 205, + line: 212, anchor: "guard provider == .grok, let g = snapshot?.grokUsage else { return nil }", expectedProviderIDs: ["grok"], expectedReferenceCount: 1, @@ -4108,7 +4109,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 239, + line: 246, anchor: "guard provider == .elevenlabs, let e = snapshot?.elevenLabsUsage else { return nil }", expectedProviderIDs: ["elevenlabs"], expectedReferenceCount: 1, @@ -4116,7 +4117,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 257, + line: 264, anchor: "guard provider == .deepgram, let d = snapshot?.deepgramUsage else { return nil }", expectedProviderIDs: ["deepgram"], expectedReferenceCount: 1, @@ -4124,7 +4125,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 275, + line: 282, anchor: "guard provider == .groq, let g = snapshot?.groqUsage else { return nil }", expectedProviderIDs: ["groq", "llmproxy"], expectedReferenceCount: 2, @@ -4132,7 +4133,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 315, + line: 322, anchor: "guard provider == .claude, let a = snapshot?.claudeAdminAPIUsage else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -4140,7 +4141,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 353, + line: 360, anchor: "guard provider == .claude else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -4148,7 +4149,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 406, + line: 413, anchor: "guard provider == .opencodego,", expectedProviderIDs: ["opencodego"], expectedReferenceCount: 1, @@ -4156,7 +4157,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 421, + line: 428, anchor: "guard provider == .minimax,", expectedProviderIDs: ["minimax"], expectedReferenceCount: 1, @@ -4164,7 +4165,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 478, + line: 485, anchor: "let paceLabel: String? = pace.map { UsagePaceText.weeklySummary(provider: .codex, pace: $0) }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4172,7 +4173,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 522, + line: 529, anchor: "guard provider == .mistral, let m = snapshot?.mistralUsage, !m.daily.isEmpty else {", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, @@ -4180,7 +4181,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 555, + line: 562, anchor: "guard provider == .openrouter, let o = snapshot?.openRouterUsage else { return nil }", expectedProviderIDs: ["openrouter"], expectedReferenceCount: 1, @@ -4188,7 +4189,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 578, + line: 585, anchor: "guard provider == .azureopenai, let a = snapshot?.azureOpenAIUsage else { return nil }", expectedProviderIDs: ["azureopenai"], expectedReferenceCount: 1, @@ -4196,7 +4197,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 594, + line: 601, anchor: "guard provider == .alibabatokenplan, let a = snapshot?.alibabaTokenPlanUsage else { return nil }", expectedProviderIDs: ["alibabatokenplan"], expectedReferenceCount: 1, @@ -4204,7 +4205,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact mapper translates provider-native snapshot fields into the versioned Mac-to-iPhone wire envelope."), AllowedProviderConstruct( path: "Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift", - line: 612, + line: 619, anchor: "guard provider == .deepseek, let ds = snapshot?.deepseekUsage else { return nil }", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -4530,15 +4531,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "QuotaKit keeps xAI charts while suppressing duplicate generic detail rows when its product-owned balance view is present."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 154, - anchor: "let codexRequests = providers.contains(.codex)", + line: 173, + anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 208, + line: 230, anchor: "let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4546,31 +4547,39 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 228, - anchor: "let codexRequests = providers.contains(.codex)", + line: 250, + anchor: "let codexSources = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 249, + line: 272, anchor: "for provider in providers where provider != .codex {", + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 7, + expectedReferenceFingerprint: ["codex@0", "grok@3", "grok@5", "grok@6", "grok@10", "grok@11", "grok@14"], + reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), + AllowedProviderConstruct( + path: "Sources/CodexBar/SpendDashboardController.swift", + line: 561, + anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], - reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), + reason: "This exact shared construct preserves the provider-owned local ledger when global scanning is off."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 633, + line: 623, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@8"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 697, + line: 716, anchor: "guard provider != .codex else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4578,7 +4587,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1385, + line: 1547, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4586,7 +4595,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 349, + line: 352, anchor: "self.snapshots[.codex] = snapshot", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -4594,7 +4603,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 780, + line: 783, anchor: "guard provider == .codex else { return outcome }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4602,7 +4611,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 871, + line: 874, anchor: "self.providerSpecs[.codex]?.descriptor", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4610,7 +4619,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 985, + line: 988, anchor: "let originalManualToken = provider == .stepfun ? self.settings.stepfunToken : nil", expectedProviderIDs: ["stepfun"], expectedReferenceCount: 1, @@ -4618,7 +4627,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1028, + line: 1031, anchor: "guard let self, provider == .stepfun,", expectedProviderIDs: ["stepfun"], expectedReferenceCount: 1, @@ -4626,7 +4635,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1131, + line: 1135, anchor: "guard let snapshot = self.lastKnownResetSnapshots[.codex],", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4634,7 +4643,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1149, + line: 1153, anchor: "return self.lastKnownResetSnapshots[.codex]", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -4642,7 +4651,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1360, + line: 1364, anchor: "if let resultEmail = CodexIdentityResolver.normalizeEmail(scoped.accountEmail(for: .codex)),", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4650,13 +4659,14 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1435, + line: 1439, anchor: "guard self.isCurrentProviderRefreshGeneration(.codex, generation: generation) else { return }", expectedProviderIDs: ["codex"], - expectedReferenceCount: 9, + expectedReferenceCount: 10, expectedReferenceFingerprint: [ "codex@0", "codex@6", + "codex@13", "codex@17", "codex@20", "codex@22", @@ -4668,7 +4678,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact Codex publication cluster preserves QuotaKit account-scoped warnings, history, and refresh guards."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1478, + line: 1482, anchor: "self.lastFetchAttempts[.codex] = outcome.attempts", expectedProviderIDs: ["codex"], expectedReferenceCount: 5, @@ -4676,7 +4686,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1519, + line: 1523, anchor: "provider == .deepseek", expectedProviderIDs: ["claude", "deepseek"], expectedReferenceCount: 2, @@ -4684,7 +4694,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This QuotaKit publication cluster preserves DeepSeek profiles and Claude account-scoped pace warnings."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1563, + line: 1567, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -4692,7 +4702,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1546, + line: 1550, anchor: "if provider == .deepseek {", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -4700,7 +4710,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", - line: 1589, + line: 1593, anchor: "if provider == .deepseek {", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -4708,7 +4718,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This QuotaKit failure path preserves DeepSeek profile-transition recovery semantics."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 221, + line: 226, anchor: "guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -4716,7 +4726,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 245, + line: 250, anchor: "guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex),", expectedProviderIDs: ["codex"], expectedReferenceCount: 9, @@ -4734,7 +4744,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 282, + line: 287, anchor: "return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing", expectedProviderIDs: ["claude", "codex", "vertexai"], expectedReferenceCount: 4, @@ -4742,7 +4752,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 347, + line: 352, anchor: "guard provider == .cursor else {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -4750,7 +4760,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 413, + line: 418, anchor: "if provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -4758,7 +4768,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 434, + line: 439, anchor: "guard provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -4766,24 +4776,28 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 453, + line: 461, anchor: "case .openai:", - expectedProviderIDs: ["mistral", "openai", "opencodego", "openrouter"], - expectedReferenceCount: 8, + expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], + expectedReferenceCount: 12, expectedReferenceFingerprint: [ "openai@0", "mistral@2", "opencodego@4", "openrouter@12", - "mistral@21", - "openai@21", - "opencodego@21", - "openrouter@21", + "xai@14", + "grok@16", + "grok@28", + "mistral@28", + "openai@28", + "opencodego@28", + "openrouter@28", + "xai@28", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 516, + line: 531, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, diff --git a/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift b/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift index 6f7b62a6a..1fbbecc72 100644 --- a/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift +++ b/Tests/CodexBarTests/RPCChildProcessTeardownTests.swift @@ -10,6 +10,101 @@ import Glibc @Suite(.serialized) struct RPCChildProcessTeardownTests { + @Test + func `RPC stdin writes after child teardown fail without aborting`() throws { + let stdin = RPCChildProcessInput() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/cat") + process.standardInput = stdin.pipe + process.standardOutput = Pipe() + process.standardError = Pipe() + try process.run() + + RPCChildProcessTeardown.terminate(process: process, stdin: stdin) + + #expect(throws: (any Error).self) { + try stdin.write(Data("{\"id\":1}\n".utf8)) + } + stdin.close() + } + + @Test + func `RPC stdin writes to an unexpectedly exited child throw without aborting`() throws { + let stdin = RPCChildProcessInput() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/cat") + process.standardInput = stdin.pipe + process.standardOutput = Pipe() + process.standardError = Pipe() + try process.run() + + process.terminate() + process.waitUntilExit() + defer { stdin.close() } + + #expect(throws: (any Error).self) { + try stdin.write(Data("{\"id\":1}\n".utf8)) + } + } + + @Test + func `Codex RPC reports a normal failure when its child closes stdin`() async throws { + let scriptURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-closed-stdin-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: scriptURL) } + + let script = """ + #!/usr/bin/python3 -S + import os + import sys + + sys.stdin.readline() + os.close(0) + print('{"id":1,"result":{}}', flush=True) + os._exit(0) + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let fetcher = UsageFetcher( + environment: ["CODEX_CLI_PATH": scriptURL.path], + initializeTimeoutSeconds: 5, + requestTimeoutSeconds: 2) + + let error = await #expect(throws: RPCWireError.self) { + _ = try await fetcher.loadLatestCLIAccountSnapshot() + } + guard case let .requestFailed(message) = error else { + Issue.record("Expected a normal RPC request failure, got \(String(describing: error))") + return + } + #expect(message.contains("stdin closed")) + } + + @Test + func `Grok RPC requests after child shutdown fail without aborting`() async throws { + let client = try GrokRPCClient( + executable: "/bin/cat", + arguments: [], + environment: [ + "PATH": "/usr/bin:/bin", + "GROK_CLI_PATH": "/bin/cat", + ], + initializeTimeoutSeconds: 5, + requestTimeoutSeconds: 2) + + client.shutdown() + + let error = await #expect(throws: GrokRPCError.self) { + try await client.initialize() + } + guard case let .requestFailed(message) = error else { + Issue.record("Expected a normal Grok request failure, got \(String(describing: error))") + return + } + #expect(message.contains("stdin closed")) + } + @Test func `Codex RPC shutdown kills an app-server child that ignores SIGTERM`() async throws { let temporaryDirectory = FileManager.default.temporaryDirectory diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index f5ac65a90..00cacca56 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -6,6 +6,31 @@ import Testing @MainActor // swiftlint:disable:next type_body_length struct SettingsStoreCoverageTests { + @Test + func `discovered Fireworks slug merges with current config revision`() throws { + let suite = "SettingsStoreCoverageTests-fireworks-discovered-slug" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + settings.updateProviderConfig(provider: .fireworks) { entry in + entry.apiKey = "pending-api-key" + entry.region = "concurrent-region" + } + settings.persistDiscoveredFireworksAccountSlug("discovered-team") + + let current = try #require(settings.configSnapshot.providerConfig(for: .fireworks)) + #expect(current.sanitizedAPIKey == "pending-api-key") + #expect(current.region == "concurrent-region") + #expect(current.sanitizedAccountSlug == "discovered-team") + + let persisted = try #require(configStore.load()?.providerConfig(for: .fireworks)) + #expect(persisted.sanitizedAPIKey == "pending-api-key") + #expect(persisted.region == "concurrent-region") + #expect(persisted.sanitizedAccountSlug == "discovered-team") + } + @Test func `agent sessions default to opt in disabled`() { let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-agent-sessions-default") diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index eb3c64860..81b428ec1 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -77,8 +77,10 @@ struct SpendDashboardModelTests { .mistral, .bedrock, .cursor, + .grok, .opencodego, .openrouter, + .xai, ]) } @@ -836,6 +838,14 @@ struct SpendDashboardModelTests { index: 1, count: 2)) #expect(changedRequest.cacheIdentity != request.cacheIdentity) + let rebucketedRequest = try #require(SpendDashboardSource.codexRequest( + account: account, + homePath: request.homePath, + providerName: "Codex", + index: 1, + count: 2, + bucketTimeZoneIdentifier: "Pacific/Kiritimati")) + #expect(rebucketedRequest.cacheIdentity != request.cacheIdentity) let authData = Data("{\"tokens\":\"synthetic\"}".utf8) try authData.write(to: CodexAuthFingerprint.authFileURL(homePath: home.path)) diff --git a/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift b/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift index ffebe8f12..84416eea6 100644 --- a/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift @@ -6,6 +6,42 @@ import Testing @MainActor @Suite(.serialized) struct SpendDashboardOpenCodexSourceTests { + @Test + func `OpenCodex publication distinguishes unavailable and confirmed empty`() { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [], + codexAccountIdentities: [], + openCodexUsageLogsEnabled: true) + let request = SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_787_079_600), + force: false) + + let unavailable = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["TESTING_LIBRARY_VERSION": "1"]) + #expect(unavailable.observation == .unavailable) + + let confirmedEmpty = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["OPENCODEX_HOME": "/tmp/opencodex-publication-test"], + entryLoader: { _ in [] }) + #expect(confirmedEmpty.observation == .confirmedEmpty) + + let failed = SpendDashboardSource.mergingOpenCodexInputsWithObservation( + [], + request: request, + environment: ["OPENCODEX_HOME": "/tmp/opencodex-publication-test"], + entryLoader: { _ in throw CocoaError(.fileReadCorruptFile) }) + #expect(failed.observation == .unavailable) + } + @Test func `OpenCodex-only configuration still starts a dashboard load`() async { let gate = SpendDashboardLoaderGate() diff --git a/Tests/CodexBarTests/SpendDashboardPublicationTests.swift b/Tests/CodexBarTests/SpendDashboardPublicationTests.swift new file mode 100644 index 000000000..ea2d52e62 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardPublicationTests.swift @@ -0,0 +1,847 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardPublicationTests { + @Test + func `shared source observation follows regular Codex publication and bucket ownership`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-source-observation") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + settings.costUsageBucketTimeZoneIdentifier = "UTC" + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let initial = SpendDashboardSource.configuration(settings: settings, store: store) + store.startSharedSpendDashboardPublication() + defer { store.stopSharedSpendDashboardPublication() } + await Self.waitUntil { + store.spendDashboardPublication.configuration?.sourceRevisions == initial.sourceRevisions + } + + store._setTokenSnapshotForTesting( + Self.input(id: "codex", provider: .codex, cost: 1).snapshot, + provider: .codex) + let afterRegularCodexPublication = SpendDashboardSource.configuration(settings: settings, store: store) + await Self.waitUntil { + store.spendDashboardPublication.configuration?.sourceRevisions == + afterRegularCodexPublication.sourceRevisions + } + + #expect(afterRegularCodexPublication.sourceRevisions != initial.sourceRevisions) + #expect(store.spendDashboardPublication.configuration?.sourceRevisions == + afterRegularCodexPublication.sourceRevisions) + + settings.costUsageBucketTimeZoneIdentifier = "Pacific/Kiritimati" + let rebucketed = SpendDashboardSource.configuration(settings: settings, store: store) + await Self.waitUntil { + store.spendDashboardPublication.configuration?.menuOwnershipFingerprint == + rebucketed.menuOwnershipFingerprint + } + + #expect(rebucketed.menuOwnershipFingerprint != afterRegularCodexPublication.menuOwnershipFingerprint) + #expect(rebucketed.sourceOwnershipFingerprints != afterRegularCodexPublication.sourceOwnershipFingerprints) + #expect(store.spendDashboardPublication.configuration?.menuOwnershipFingerprint == + rebucketed.menuOwnershipFingerprint) + } + + @Test + func `shared publication starts and stops in-flight Codex dashboard catch-up`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-codex-catch-up") + settings.costUsageEnabled = true + let metadata = try #require(ProviderRegistry.shared.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + let missingLiveHome = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileHome = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try Self.writeCodexAuthFile(homeURL: profileHome) + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": missingLiveHome.path] + settings.updateProviderConfig(provider: .codex) { config in + config.codexProfileHomePaths = [profileHome.path] + config.codexActiveSource = .profileHome(path: profileHome.path) + } + defer { + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: profileHome) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + var statusLoadCount = 0 + store._test_spendDashboardCodexCostCatchUpStatusOverride = { _ in + statusLoadCount += 1 + return CostUsageFetcher.CodexScanCatchUpStatus( + pending: true, + progressKey: "pending", + processedBytes: 1, + totalBytes: 2, + completedFiles: 0, + totalFiles: 1) + } + store._test_spendDashboardCodexCostCatchUpSleepOverride = { _ in + try await Task.sleep(for: .seconds(60)) + } + store._test_spendDashboardCodexCostCatchUpResourceStateOverride = { + (.battery, true, .serious) + } + + store.startSharedSpendDashboardPublication() + await Self.waitUntil { + statusLoadCount > 0 && store.spendDashboardCodexCostCatchUpTask != nil + } + store.stopSharedSpendDashboardPublication() + + #expect(statusLoadCount > 0) + #expect(store.spendDashboardCodexCostCatchUpTask == nil) + #expect(store.spendDashboardCodexCostCatchUpActivity == nil) + } + + @Test + func `usage store owns one shared controller and mirrors its publication`() { + let settings = testSettingsStore(suiteName: "SpendDashboardPublicationTests-shared-owner") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let first = store.sharedSpendDashboardController() + let second = store.sharedSpendDashboardController() + + #expect(first === second) + + first.update(configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: [])) + + #expect(store.spendDashboardPublication.revision > 0) + #expect(store.spendDashboardPublication.configuration?.costUsageEnabled == false) + } + + @Test + func `controller atomically publishes canonical inputs and source truth states`() async { + let fixture = Self.fixture() + let controller = SpendDashboardController( + requestBuilder: { _ in fixture.request }, + loader: { _ in fixture.result }) + + controller.update(configuration: fixture.request.configuration) + await Self.waitUntil { !controller.isRefreshing } + + let publication = controller.publication + let sources = Dictionary(uniqueKeysWithValues: publication.sources.map { ($0.id, $0) }) + let inputs = Dictionary(uniqueKeysWithValues: publication.inputs.map { ($0.id, $0) }) + + #expect(Set(sources.keys) == ["openai", "claude", "gemini", "codex:first", "codex:second"]) + #expect(sources["openai"]?.state == .available) + #expect(inputs["openai"]?.id == "openai") + #expect(sources["claude"]?.state == .confirmedEmpty) + #expect(inputs["claude"]?.id == nil) + #expect(sources["gemini"]?.state == .unavailable) + #expect(inputs["gemini"]?.id == nil) + #expect(sources["codex:first"]?.state == .available) + #expect(inputs["codex:first"]?.id == "codex:first") + #expect(sources["codex:second"]?.state == .available) + #expect(inputs["codex:second"]?.id == "codex:second") + #expect(publication.subscriptionCount(providerScope: [.codex, .openai, .claude, .gemini]) == 5) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD") + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex, .openai, .claude, .gemini]) == 4) + } + + @Test + func `profile-home path containing pipe preserves full account identity`() async { + let pipeContainingPath = "profile:/Users/test|data|home" + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["\(pipeContainingPath)|cache-identity"]) + let request = SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Self.now, + force: false) + let expectedID = "codex:\(pipeContainingPath)" + let result = SpendDashboardLoadResult( + inputs: [Self.input(id: expectedID, provider: .codex, cost: 4)], + failedSourceIDs: []) + let controller = SpendDashboardController( + requestBuilder: { _ in request }, + loader: { _ in result }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + + let sourceIDs = Set(controller.publication.sources.map(\.id)) + #expect(sourceIDs == [expectedID]) + #expect(controller.publication.subscriptionCount(providerScope: [.codex]) == 1) + #expect(controller.publication.inputs.first?.id == expectedID) + } + + @Test + func `failed refresh publishes retained input as stale last known`() async throws { + let initialConfiguration = Self.configuration(revision: "claude:first") + let replacementConfiguration = Self.configuration(revision: "claude:second") + let script = SpendDashboardPublicationScript( + requests: [ + Self.request(configuration: initialConfiguration), + Self.request(configuration: replacementConfiguration, unavailableSourceIDs: ["claude"]), + ], + results: [ + SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 3)], + failedSourceIDs: []), + SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["claude"]), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await script.nextRequest(mode: mode) }, + loader: { request in await script.nextResult(request: request) }) + + controller.update(configuration: initialConfiguration) + await Self.waitUntil { !controller.isRefreshing } + controller.update(configuration: replacementConfiguration) + await Self.waitUntil { !controller.isRefreshing && controller.generation == 2 } + + let source = try #require(controller.publication.sources.first { $0.id == "claude" }) + #expect(source.state == .staleLastKnown) + #expect(controller.publication.inputs.first { $0.id == "claude" }?.snapshot.last30DaysCostUSD == 3) + let overview = controller.publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD") + #expect(overview.groups.isEmpty) + } + + @Test + func `visible Codex source without a loadable input remains unavailable`() async throws { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["readable|cache-a", "unreadable|cache-b"]) + let request = Self.request(configuration: configuration) + let result = SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:readable", provider: .codex, cost: 2)], + failedSourceIDs: []) + let controller = SpendDashboardController( + requestBuilder: { _ in request }, + loader: { _ in result }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + + let unreadable = try #require(controller.publication.sources.first { $0.id == "codex:unreadable" }) + #expect(unreadable.state == .unavailable) + } + + @Test + func `confirmed empty subscription completes the subtotal without adding spend`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [Self.input(id: "openai", provider: .openai, cost: 7)], + sources: [ + SpendSourcePublication( + id: "openai", + provider: .openai, + displayName: "OpenAI", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "claude", + provider: .claude, + displayName: "Claude", + role: .subscription, + state: .confirmedEmpty), + ]) + let scope: Set = [.openai, .claude] + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: scope) + let summary = OverviewSpendSummary( + model: model, + providerCount: publication.subscriptionCount(providerScope: scope), + knownCostProviderCount: publication.knownCostSubscriptionCount(model: model, providerScope: scope), + knownTokenProviderCount: publication.knownTokenSubscriptionCount(model: model, providerScope: scope)) + + #expect(summary.providerCoverageText == "1 of 2 subscriptions have spend") + #expect(!summary.isPartial) + #expect(summary.primarySpendText == "$7.00") + } + + @Test + func `confirmed empty Grok session scan is known zero tokens but unknown spend`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [], + sources: [SpendSourcePublication( + id: UsageProvider.grok.rawValue, + provider: .grok, + displayName: "Grok", + role: .subscription, + state: .confirmedEmpty)]) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: [.grok]) + + #expect(publication.knownTokenSubscriptionCount(model: model, providerScope: [.grok]) == 1) + #expect(publication.knownCostSubscriptionCount(model: model, providerScope: [.grok]) == 0) + } + + @Test + func `available unpriced source keeps cost subtotal partial`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [ + Self.input(id: "openai", provider: .openai, cost: 7), + Self.input(id: "claude", provider: .claude, cost: nil), + ], + sources: [ + SpendSourcePublication( + id: "openai", + provider: .openai, + displayName: "OpenAI", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "claude", + provider: .claude, + displayName: "Claude", + role: .subscription, + state: .available), + ]) + let scope: Set = [.openai, .claude] + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: scope) + let summary = OverviewSpendSummary( + model: model, + providerCount: publication.subscriptionCount(providerScope: scope), + knownCostProviderCount: publication.knownCostSubscriptionCount(model: model, providerScope: scope), + knownTokenProviderCount: publication.knownTokenSubscriptionCount(model: model, providerScope: scope)) + + #expect(summary.isPartial) + #expect(summary.primarySpendText == "~$7.00") + } + + @Test + func `hiding every account source leaves no phantom provider denominator`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [], + sources: [ + SpendSourcePublication( + id: "codex:first", + provider: .codex, + displayName: "Codex 1", + role: .subscription, + state: .unavailable), + SpendSourcePublication( + id: "codex:second", + provider: .codex, + displayName: "Codex 2", + role: .subscription, + state: .unavailable), + ]) + + #expect(publication.subscriptionCount( + providerScope: [.codex], + hiddenSourceIDs: ["codex:first", "codex:second"]) == 0) + } + + @Test + func `OpenCodex replacement is one known coverage source for multiple native accounts`() { + let nativeInputs = [ + Self.input(id: "codex:first", provider: .codex, cost: 2), + Self.input(id: "codex:second", provider: .codex, cost: 3), + ] + let openCodex = SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.input(id: "unused", provider: .codex, cost: 8).snapshot, + sourceKind: .openCodex) + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: nativeInputs + [openCodex], + sources: [ + SpendSourcePublication( + id: "codex:first", + provider: .codex, + displayName: "Codex 1", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "codex:second", + provider: .codex, + displayName: "Codex 2", + role: .subscription, + state: .available), + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .available), + ]) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + hideNativeCodexWhenOpenCodexPresent: true, + providerScope: [.codex]) + + #expect(model.groups.flatMap(\.providers).map(\.id) == [SpendDashboardModel.openCodexSourceID]) + #expect(publication.subscriptionCount( + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + } + + @Test + func `non-Codex OpenCodex enrichment does not replace native Codex`() { + let nativeCodex = Self.input(id: "codex:first", provider: .codex, cost: 2) + let openCodexKimi = SpendDashboardModel.ProviderInput( + id: "kimi", + provider: .kimi, + displayName: "Kimi", + snapshot: Self.input(id: "unused", provider: .kimi, cost: 8).snapshot, + sourceKind: .openCodex) + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [nativeCodex, openCodexKimi], + sources: [ + SpendSourcePublication( + id: nativeCodex.id, + provider: .codex, + displayName: nativeCodex.displayName, + role: .subscription, + state: .available), + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .available), + SpendSourcePublication( + id: openCodexKimi.id, + provider: .kimi, + displayName: openCodexKimi.displayName, + role: .enrichment, + state: .available), + ]) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + hideNativeCodexWhenOpenCodexPresent: true, + providerScope: [.codex, .kimi]) + + #expect(Set(model.groups.flatMap(\.providers).map(\.id)) == [nativeCodex.id, openCodexKimi.id]) + #expect(publication.subscriptionCount( + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex], + hideNativeCodexWhenOpenCodexPresent: true) == 1) + } + + @Test + func `visible standalone OpenCodex remains when every native Codex account is hidden`() { + let nativeInputs = [ + Self.input(id: "codex:first", provider: .codex, cost: 2), + Self.input(id: "codex:second", provider: .codex, cost: 3), + ] + let standaloneOpenCodex = SpendDashboardModel.ProviderInput( + id: UsageProvider.codex.rawValue, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.input(id: "unused", provider: .codex, cost: 8).snapshot, + sourceKind: .openCodex) + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: nativeInputs + [standaloneOpenCodex], + sources: [ + SpendSourcePublication( + id: "codex:first", + provider: .codex, + displayName: "Codex 1", + role: .subscription, + state: .available), + SpendSourcePublication( + id: "codex:second", + provider: .codex, + displayName: "Codex 2", + role: .subscription, + state: .available), + SpendSourcePublication( + id: standaloneOpenCodex.id, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .available), + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex logs", + role: .enrichment, + state: .available), + ]) + let hidden: Set = ["codex:first", "codex:second"] + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + hiddenSourceIDs: hidden, + providerScope: [.codex]) + + #expect(model.groups.flatMap(\.providers).map(\.id) == [standaloneOpenCodex.id]) + #expect(publication.subscriptionCount(providerScope: [.codex], hiddenSourceIDs: hidden) == 1) + #expect(publication.knownCostSubscriptionCount( + model: model, + providerScope: [.codex], + hiddenSourceIDs: hidden) == 1) + } + + @Test + func `confirmed empty OpenCodex-only source is a known zero`() { + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: Self.now, + isRefreshing: false, + inputs: [], + sources: [ + SpendSourcePublication( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + role: .enrichment, + state: .confirmedEmpty), + ]) + let model = publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: [.codex]) + let providerCount = publication.subscriptionCount(providerScope: [.codex]) + let knownCostCount = publication.knownCostSubscriptionCount(model: model, providerScope: [.codex]) + let summary = OverviewSpendSummary( + model: model, + providerCount: providerCount, + knownCostProviderCount: knownCostCount, + knownTokenProviderCount: publication.knownTokenSubscriptionCount( + model: model, + providerScope: [.codex])) + + #expect(providerCount == 1) + #expect(knownCostCount == 1) + #expect(summary.primarySpendText == "No usage yet") + } + + @Test + func `controller publishes one canonical OpenCodex source identity`() async { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [], + openCodexUsageLogsEnabled: true) + let request = Self.request(configuration: configuration) + let openCodex = SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.input(id: "unused", provider: .codex, cost: 8).snapshot, + sourceKind: .openCodex) + let controller = SpendDashboardController( + requestBuilder: { _ in request }, + loader: { _ in + SpendDashboardLoadResult( + inputs: [openCodex], + failedSourceIDs: [], + openCodexObservation: .available) + }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.publication.sources.map(\.id) == [SpendDashboardModel.openCodexSourceID]) + } + + @Test + func `empty and unavailable catalogs have distinct summary copy`() { + let model = SpendDashboardModel(requestedDays: 30, groups: []) + let empty = OverviewSpendSummary( + model: model, + providerCount: 2, + knownCostProviderCount: 2, + knownTokenProviderCount: 2) + let unavailable = OverviewSpendSummary( + model: model, + providerCount: 2, + knownCostProviderCount: 0, + knownTokenProviderCount: 0) + + #expect(empty.primarySpendText == "No usage yet") + #expect(unavailable.primarySpendText == "Spend unavailable") + } + + @Test + func `overview projection is synchronous and reuses published inputs without loading`() async { + let fixture = Self.fixture() + let calls = SpendDashboardPublicationLoadCounter() + let controller = SpendDashboardController( + requestBuilder: { _ in fixture.request }, + loader: { _ in + await calls.recordLoad() + return fixture.result + }) + + controller.update(configuration: fixture.request.configuration) + await Self.waitUntil { !controller.isRefreshing } + let callsBeforeProjection = await calls.count + + let model = controller.publication.model( + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD", + providerScope: [.codex]) + let providerIDs = model.groups.flatMap { group in + group.providers.map(\.id) + } + + #expect(await calls.count == callsBeforeProjection) + #expect(Set(providerIDs) == ["codex:first", "codex:second"]) + #expect(model.groups.first?.totalCost == 5) + } + + private static func fixture() -> (request: SpendDashboardLoadRequest, result: SpendDashboardLoadResult) { + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [ + UsageProvider.codex.rawValue, + UsageProvider.openai.rawValue, + UsageProvider.claude.rawValue, + UsageProvider.gemini.rawValue, + ], + codexAccountIdentities: ["first|first-cache", "second|second-cache"]) + let openAI = Self.input(id: "openai", provider: .openai, cost: 7) + return ( + request: SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [openAI], + unavailableSourceIDs: ["gemini"], + confirmedEmptySourceIDs: ["claude"], + codexRequests: [], + now: Self.now, + force: false), + result: SpendDashboardLoadResult( + inputs: [ + openAI, + Self.input(id: "codex:first", provider: .codex, cost: 2), + Self.input(id: "codex:second", provider: .codex, cost: 3), + ], + failedSourceIDs: ["gemini"])) + } + + private static func configuration(revision: String) -> SpendDashboardConfiguration { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:stable-owner"], + sourceRevisions: [revision]) + } + + private static func request( + configuration: SpendDashboardConfiguration, + unavailableSourceIDs: Set = []) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: unavailableSourceIDs, + codexRequests: [], + now: self.now, + force: false) + } + + private static func input( + id: String, + provider: UsageProvider, + cost: Double?) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + currencyCode: "USD", + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: self.now)) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for Spend Dashboard publication") + } + + private static func writeCodexAuthFile(homeURL: URL) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let header = try JSONSerialization.data(withJSONObject: ["alg": "none"]) + let payload = try JSONSerialization.data(withJSONObject: [ + "email": "shared-publication@example.com", + "chatgpt_plan_type": "pro", + ]) + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + let token = "\(base64URL(header)).\(base64URL(payload))." + let auth = [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": token, + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} + +private actor SpendDashboardPublicationScript { + private var requests: [SpendDashboardLoadRequest] + private var results: [SpendDashboardLoadResult] + + init(requests: [SpendDashboardLoadRequest], results: [SpendDashboardLoadResult]) { + self.requests = requests + self.results = results + } + + func nextRequest(mode: SpendDashboardRequestBuildMode) -> SpendDashboardLoadRequest { + precondition(!self.requests.isEmpty, "Unexpected Spend Dashboard publication request") + let request = self.requests.removeFirst() + return SpendDashboardLoadRequest( + configuration: request.configuration, + capturedInputs: request.capturedInputs, + unavailableSourceIDs: request.unavailableSourceIDs, + confirmedEmptySourceIDs: request.confirmedEmptySourceIDs, + codexRequests: request.codexRequests, + now: request.now, + force: mode.forcesLoader) + } + + func nextResult(request: SpendDashboardLoadRequest) -> SpendDashboardLoadResult { + guard !self.results.isEmpty else { + Issue.record("Unexpected Spend Dashboard publication load for \(request.configuration.providerIDs)") + return SpendDashboardLoadResult(inputs: [], failedSourceIDs: []) + } + return self.results.removeFirst() + } +} + +private actor SpendDashboardPublicationLoadCounter { + private(set) var count = 0 + + func recordLoad() { + self.count += 1 + } +} diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index ef426a8f5..81e7763f8 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -447,6 +447,8 @@ struct SpendDashboardSourceConcurrencyTests { controller.update(configuration: replacement) #expect(controller.generation == inFlightGeneration) #expect(controller.configuration == replacement) + #expect(controller.publication.configuration == replacement) + #expect(controller.publication.isRefreshing) await loaderGate.resume( result: SpendDashboardLoadResult( diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 256558d6c..3a01cf6d3 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -264,6 +264,7 @@ struct StatusItemBalanceDisplayTests { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: Date())) @@ -1089,6 +1090,7 @@ extension StatusItemBalanceDisplayTests { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: Date())) @@ -1144,6 +1146,7 @@ extension StatusItemBalanceDisplayTests { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: Date())) diff --git a/Tests/CodexBarTests/StatusItemConditionalSignatureTests.swift b/Tests/CodexBarTests/StatusItemConditionalSignatureTests.swift new file mode 100644 index 000000000..a42c415d9 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemConditionalSignatureTests.swift @@ -0,0 +1,200 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +/// A conditional predicate can depend on data no display token exposes. When the observation signature +/// misses that dependency the observer skips `updateIcons()` and the menu bar keeps rendering the branch +/// that was true before the data moved, so each case here pins one such dependency. +@MainActor +@Suite(.serialized) +struct StatusItemConditionalSignatureTests { + @Test + func `over-quota used-direction lane predicate moves the signature`() { + let harness = Self.makeHarness( + suite: "StatusItemConditionalSignatureTests-lane-over-quota", + metric: .primaryLane, + direction: .used, + comparison: .greaterThan, + threshold: 105) + // Rendered lanes show remaining, which clamps at zero: both snapshots display 0% remaining. + harness.settings.usageBarsShowUsed = false + + let below = harness.signature(primaryUsedPercent: 104) + let above = harness.signature(primaryUsedPercent: 106) + + #expect(below.contains("layoutCondWindows=primaryLane=")) + #expect(below != above) + } + + @Test + func `countdown predicate follows a moved reset timestamp`() { + let harness = Self.makeHarness( + suite: "StatusItemConditionalSignatureTests-reset-moved", + metric: .sessionResetsIn, + direction: .used, + comparison: .lessThan, + threshold: 2) + + // Identical usage, different reset instant: only the countdown predicate can tell them apart. + let near = harness.signature(primaryUsedPercent: 30, sessionResetInHours: 1) + let far = harness.signature(primaryUsedPercent: 30, sessionResetInHours: 5) + + #expect(near.contains("layoutCondWindows=sessionResetsIn=")) + #expect(near != far) + } + + @Test + func `cost predicate signs the unrounded amount`() { + let harness = Self.makeHarness( + suite: "StatusItemConditionalSignatureTests-cost-subcent", + metric: .costToday, + direction: .used, + comparison: .greaterThan, + threshold: 1.2345) + + // Both amounts format to "$1.23", so only the numeric component can separate them. + let below = harness.signature(primaryUsedPercent: 30, todayCostUSD: 1.2344) + let above = harness.signature(primaryUsedPercent: 30, todayCostUSD: 1.2346) + + #expect(below.contains("todayUSD=")) + #expect(below != above) + } + + /// Thresholds are USD. `UsageFormatter.convertedCost` returns the source amount unchanged when it has + /// no rate for the provider's currency, so handing that value over would compare a foreign amount + /// against a USD threshold. The display string must still render in the provider's own currency. + @Test + func `cost in an unconvertible currency yields no USD metric`() { + let harness = Self.makeHarness( + suite: "StatusItemConditionalSignatureTests-cost-currency", + metric: .costToday, + direction: .used, + comparison: .greaterThan, + threshold: 5) + + _ = harness.signature(primaryUsedPercent: 30, todayCostUSD: 6, currencyCode: "XXX") + let unconvertible = harness.controller.menuBarLayoutCosts(provider: .claude) + #expect(unconvertible.todayUSD == nil) + #expect(unconvertible.last30DaysUSD == nil) + // The rendered text is unaffected: it stays in the provider's reported currency. + #expect(unconvertible.today != nil) + + _ = harness.signature(primaryUsedPercent: 30, todayCostUSD: 6, currencyCode: "USD") + let usd = harness.controller.menuBarLayoutCosts(provider: .claude) + #expect(usd.todayUSD == 6) + } + + @MainActor + private struct Harness { + let settings: SettingsStore + let store: UsageStore + let controller: StatusItemController + let now: Date + + func signature( + primaryUsedPercent: Double, + sessionResetInHours: Double = 1, + todayCostUSD: Double? = nil, + currencyCode: String = "USD") + -> String + { + self.store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: primaryUsedPercent, + windowMinutes: 300, + resetsAt: self.now.addingTimeInterval(sessionResetInHours * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: self.now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: self.now), + provider: .claude) + if let todayCostUSD { + self.store._setTokenSnapshotForTesting( + Self.tokenSnapshot( + todayCostUSD: todayCostUSD, + currencyCode: currencyCode, + now: self.now), + provider: .claude) + } + return self.controller.storeIconObservationSignature() + } + + private static func tokenSnapshot( + todayCostUSD: Double, + currencyCode: String, + now: Date) + -> CostUsageTokenSnapshot + { + let formatter = DateFormatter() + formatter.calendar = .current + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: todayCostUSD, + currencyCode: currencyCode, + daily: [ + CostUsageDailyReport.Entry( + date: formatter.string(from: now), + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: todayCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + } + } + + private static func makeHarness( + suite: String, + metric: MenuBarConditionalMetric, + direction: MenuBarConditionalDirection, + comparison: MenuBarConditionalComparison, + threshold: Double) + -> Harness + { + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + + let conditional = MenuBarLayoutConditional( + name: "gate", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate( + metric: metric, + direction: direction, + comparison: comparison, + threshold: threshold))], + thenToken: .resetCountdown, + elseToken: .hidden) + settings.menuBarLayoutConditionals = [conditional] + settings.menuBarLayout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + if let claudeMeta = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + return Harness(settings: settings, store: store, controller: controller, now: Date()) + } +} diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index 8cae827f9..6d9f86358 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -439,7 +439,7 @@ struct StatusItemIconObservationSignatureTests { #expect(store.snapshot(for: .codex)?.primary?.usedPercent == usagePrimaryPercent) #expect(controller.lastObservedStoreIconWorkSignature != baseline) #expect( - controller.menuBarLayoutCostStrings(provider: .codex).last30Days == + controller.menuBarLayoutCosts(provider: .codex).last30Days == UsageFormatter.currencyString(12.50, currencyCode: "USD")) } diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift index 18f27e501..7a7e3b6ec 100644 --- a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -89,6 +89,76 @@ extension StatusMenuTests { #expect(controller.measuredStandardMenuWidthCache == firstCache) } + @Test + func `agent session rows use the menu width instead of their natural title width`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1000) + let shortSession = AgentSession( + id: "session", + provider: .codex, + source: .cli, + state: .active, + pid: 42, + cwd: "/tmp/short", + projectName: "short", + startedAt: nil, + lastActivityAt: now, + transcriptPath: nil, + host: "local") + let longSession = AgentSession( + id: "session", + provider: .codex, + source: .cli, + state: .active, + pid: 42, + cwd: "/tmp/long", + projectName: String(repeating: "very-long-project-name-", count: 24), + startedAt: nil, + lastActivityAt: now, + transcriptPath: nil, + host: "local") + let shortSection = MenuDescriptor.agentSessionsSection( + localSessions: [shortSession], + remoteHosts: [], + now: now) + let longSection = MenuDescriptor.agentSessionsSection( + localSessions: [longSession], + remoteHosts: [], + now: now) + let baseWidth = StatusItemController.menuCardBaseWidth + + let shortWidth = controller.measuredStandardMenuWidth(for: [shortSection], baseWidth: baseWidth) + let longWidth = controller.measuredStandardMenuWidth(for: [longSection], baseWidth: baseWidth) + + #expect(longWidth == shortWidth) + #expect(controller.measuredStandardMenuWidthCache.count == 1) + + let menu = NSMenu() + controller.addActionableSections([longSection], to: menu, width: longWidth) + guard menu.items.indices.contains(1), + case let .action(title, .focusAgentSession) = longSection.entries[1], + let view = menu.items[1].view + else { + Issue.record("Expected a hosted Agent Session action row") + return + } + let row = menu.items[1] + + #expect(row.title.isEmpty) + #expect(row.toolTip == title) + #expect(row.representedObject as? String == "agentSession:local:session") + #expect(row.identifier != nil) + #expect(view.frame.width == longWidth) + } + @Test func `fingerprinted menu card height cache survives content version invalidation`() { let controller = self.makeHeightCacheController() diff --git a/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift index 0e491e39c..7468247e6 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift @@ -4,6 +4,224 @@ import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `overview spend uses the configured dashboard bucket calendar`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 1 + let now = Date(timeIntervalSince1970: 1_787_079_600) + let currentOffset = Calendar.current.timeZone.secondsFromGMT(for: now) + let bucketIdentifier = currentOffset == 14 * 60 * 60 + ? "Etc/GMT+12" + : "Pacific/Kiritimati" + settings.costUsageBucketTimeZoneIdentifier = bucketIdentifier + let bucketCalendar = settings.costUsageBucketCalendar + let dayComponents = bucketCalendar.dateComponents([.year, .month, .day], from: now) + let year = try #require(dayComponents.year) + let month = try #require(dayComponents.month) + let dayOfMonth = try #require(dayComponents.day) + let day = String(format: "%04d-%02d-%02d", year, month, dayOfMonth) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 100, + last30DaysCostUSD: 1, + historyDays: 1, + costProvenance: .listPriceEstimate, + daily: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: 60, + outputTokens: 40, + totalTokens: 100, + requestCount: 1, + costUSD: 1, + modelsUsed: ["test-model"], + modelBreakdowns: nil), + ], + updatedAt: now), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let model = controller.overviewSpendDashboardModel(providers: [.codex], now: now) + let group = try #require(model.groups.first) + let bucketStart = bucketCalendar.startOfDay(for: now) + + #expect(bucketStart != Calendar.current.startOfDay(for: now)) + #expect(group.chartDomain.lowerBound == bucketStart) + #expect(group.timeZone.identifier == bucketCalendar.timeZone.identifier) + #expect(group.totalCost == 1) + #expect(group.totalTokens == 100) + #expect(group.dailyPoints.map(\.day) == [bucketStart]) + } + + @Test + func `shared overview keeps Codex local ledger when global cost tracking is off`() async { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = false + settings.codexLocalSessionCostLedgerEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date(timeIntervalSince1970: 1_787_079_600) + let configuration = SpendDashboardSource.configuration(settings: settings, store: store) + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly, + now: now) + let input = SpendDashboardModel.ProviderInput( + id: "codex:local", + provider: .codex, + displayName: "Codex", + snapshot: CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 4, + last30DaysTokens: 10, + last30DaysCostUSD: 4, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-17", + inputTokens: 5, + outputTokens: 5, + totalTokens: 10, + costUSD: 4, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now)) + store.spendDashboardPublication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: configuration, + loadedAt: now, + isRefreshing: false, + inputs: [input], + sources: [ + SpendSourcePublication( + id: input.id, + provider: .codex, + displayName: input.displayName, + role: .subscription, + state: .available), + ]) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(configuration.costUsageEnabled) + #expect(configuration.providerIDs == [UsageProvider.codex.rawValue]) + #expect(request.configuration.costUsageEnabled) + #expect(request.configuration.providerIDs == [UsageProvider.codex.rawValue]) + #expect(controller.overviewSpendDashboardModel(providers: [.codex], now: now).groups.first?.totalCost == 4) + } + + @Test + func `overview consumes shared publication without starting a loader`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + let providers: [UsageProvider] = [.codex, .claude] + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date(timeIntervalSince1970: 1_787_079_600) + func input(id: String, provider: UsageProvider, cost: Double) -> SpendDashboardModel.ProviderInput { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: id, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-17", + inputTokens: 5, + outputTokens: 5, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now)) + } + let inputs = [ + input(id: "codex:first", provider: .codex, cost: 2), + input(id: "codex:second", provider: .codex, cost: 3), + input(id: "claude", provider: .claude, cost: 7), + ] + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: providers.map(\.rawValue), + codexAccountIdentities: ["first|cache-a", "second|cache-b"], + menuOwnershipFingerprint: SpendDashboardSource.currentMenuOwnershipFingerprint( + settings: settings, + store: store)) + store.spendDashboardPublication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: configuration, + loadedAt: now, + isRefreshing: false, + inputs: inputs, + sources: inputs.map { + SpendSourcePublication( + id: $0.id, + provider: $0.provider, + displayName: $0.displayName, + role: .subscription, + state: .available) + }) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(store.sharedSpendDashboardControllerStorage == nil) + let model = controller.overviewSpendDashboardModel(providers: providers, now: now) + #expect(store.sharedSpendDashboardControllerStorage == nil) + #expect(Set(model.groups.flatMap(\.providers).map(\.id)) == ["codex:first", "codex:second", "claude"]) + #expect(model.groups.first?.totalCost == 12) + #expect(controller.overviewSpendSubscriptionCount(providers: providers) == 3) + + guard let claudeMetadata = ProviderRegistry.shared.metadata[.claude] else { + Issue.record("Claude metadata missing") + return + } + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) + let staleOwnerModel = controller.overviewSpendDashboardModel(providers: providers, now: now) + #expect(staleOwnerModel.groups.isEmpty) + } + @Test func `overview accounts for all six selected providers while summing only available spend`() { let settings = self.makeSettings() @@ -67,6 +285,113 @@ extension StatusMenuTests { #expect(summary.isPartial) } + @Test + func `overview keeps six visible providers while accounting for all seven connected providers`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = true + settings.costUsageEnabled = true + settings.costSummaryDisplayStyle = .both + let connected: [UsageProvider] = [ + .openai, + .claude, + .gemini, + .antigravity, + .openrouter, + .grok, + .codex, + ] + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: connected.contains(provider)) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let enabledRoster = store.enabledFirstPartyProvidersForDisplay() + #expect(Set(enabledRoster) == Set(connected)) + let now = Date() + let components = Calendar.current.dateComponents([.year, .month, .day], from: now) + let year = try #require(components.year) + let month = try #require(components.month) + let dayOfMonth = try #require(components.day) + let day = String(format: "%04d-%02d-%02d", year, month, dayOfMonth) + for provider in enabledRoster { + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: provider) + } + func snapshot(cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 0, + last30DaysCostUSD: cost, + costProvenance: .vendorMetered, + daily: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 1, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let scopes = controller.overviewProviderScopes(enabledProviders: enabledRoster) + let hiddenProvider = try #require(scopes.spend.first { !scopes.visible.contains($0) }) + let pricedProviders = [scopes.visible[0], scopes.visible[1], hiddenProvider] + store._setTokenSnapshotForTesting(snapshot(cost: 35.09), provider: pricedProviders[0]) + store._setTokenSnapshotForTesting(snapshot(cost: 39.79), provider: pricedProviders[1]) + store._setTokenSnapshotForTesting(snapshot(cost: 10.12), provider: pricedProviders[2]) + store._setTokenSnapshotForTesting(snapshot(cost: 1000), provider: .cursor) + + let duplicateScopes = controller.overviewProviderScopes( + enabledProviders: enabledRoster + [enabledRoster[0]]) + let model = controller.overviewSpendDashboardModel(providers: scopes.spend, now: now) + let summary = OverviewSpendSummary(model: model, providerCount: scopes.spend.count) + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + let ids = menu.items.compactMap { $0.representedObject as? String } + let overviewRows = ids.filter { $0.hasPrefix("overviewRow-") } + + #expect(scopes.visible.count == 6) + #expect(!scopes.visible.contains(hiddenProvider)) + #expect(scopes.spend == enabledRoster) + #expect(duplicateScopes.spend == enabledRoster) + #expect(Set(overviewRows) == Set(scopes.visible.map { "overviewRow-\($0.rawValue)" })) + #expect(overviewRows.count == 6) + #expect(ids.contains("overviewSpendSummary")) + #expect(Set(model.groups.first?.providers.map(\.provider) ?? []) == Set(pricedProviders)) + #expect(abs((model.groups.first?.totalCost ?? -1) - 85) < 1e-9) + #expect(summary.primarySpendText == "~$85.00") + #expect(summary.providerCoverageText == "3 of 7 subscriptions have spend") + #expect(summary.isPartial) + } + @Test func `overview spend follows the inline display preference`() throws { for (style, enabled) in [ diff --git a/Tests/CodexBarTests/SyncCoordinatorV026MapperTests.swift b/Tests/CodexBarTests/SyncCoordinatorV026MapperTests.swift index bd21d28a8..f4338dec7 100644 --- a/Tests/CodexBarTests/SyncCoordinatorV026MapperTests.swift +++ b/Tests/CodexBarTests/SyncCoordinatorV026MapperTests.swift @@ -217,7 +217,11 @@ struct SyncCoordinatorV026MapperTests { total: Double = 1000, bonusUsed: Double? = nil, bonusTotal: Double? = nil, - bonusExpiryDays: Int? = nil) -> KiroUsageDetails + bonusExpiryDays: Int? = nil, + overageCreditsUsed: Double? = nil, + estimatedOverageCostUSD: Double? = nil, + usageLimits: KiroUsageLimits? = nil, + resetsAt: Date? = nil) -> KiroUsageDetails { KiroUsageDetails( planName: plan, @@ -230,10 +234,12 @@ struct SyncCoordinatorV026MapperTests { bonusCreditsRemaining: (bonusTotal ?? 0) - (bonusUsed ?? 0), bonusExpiryDays: bonusExpiryDays, overagesStatus: nil, - overageCreditsUsed: nil, - estimatedOverageCostUSD: nil, + overageCreditsUsed: overageCreditsUsed, + estimatedOverageCostUSD: estimatedOverageCostUSD, manageURL: nil, - contextUsage: nil) + contextUsage: nil, + usageLimits: usageLimits, + resetsAt: resetsAt) } @Test @@ -278,6 +284,57 @@ struct SyncCoordinatorV026MapperTests { #expect(result?.creditsPercent == nil) } + @Test + func `Kiro mapper: preserves CLI reset when limits enrichment is unavailable`() { + let resetsAt = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + kiroUsage: Self.makeKiroDetails(resetsAt: resetsAt), + updatedAt: Self.now) + + let result = SyncCoordinator.mapKiroCredits(provider: .kiro, snapshot: snapshot) + + #expect(result?.resetsAt == resetsAt) + #expect(result?.overageCreditsCap == nil) + } + + @Test + func `Kiro mapper: carries overage cap charges currency and reset without mislabeling legacy USD`() throws { + let resetsAt = Date(timeIntervalSince1970: 1_800_000_000) + let limits = KiroUsageLimits( + planLimit: 1000, + planUsed: 1000, + overageUsed: 125, + overageCap: 500, + overageEnabled: true, + overageCharges: 18.75, + overageRate: 0.15, + currencyCode: "EUR", + resetsAt: resetsAt) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + kiroUsage: Self.makeKiroDetails( + used: 1000, + total: 1000, + overageCreditsUsed: 125, + estimatedOverageCostUSD: 18.75, + usageLimits: limits, + resetsAt: resetsAt), + updatedAt: Self.now) + + let result = try #require(SyncCoordinator.mapKiroCredits(provider: .kiro, snapshot: snapshot)) + + #expect(result.overageCreditsUsed == 125) + #expect(result.overageCreditsCap == 500) + #expect(result.overageCharges == 18.75) + #expect(result.overageChargeLimit == 75) + #expect(result.overageCurrencyCode == "EUR") + #expect(result.resetsAt == resetsAt) + #expect(result.estimatedOverageCostUSD == nil) + } + // MARK: - mapBedrockCost @Test diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index 7fd9e2ff2..5d977ff26 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -5,6 +5,66 @@ import Testing @MainActor struct UsageStoreWidgetSnapshotTests { + @Test + func `widget follows active swap account with an opaque stable owner key`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-swap-active" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_782_000_000) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 99, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + store.claudeSwapAccountSnapshots = ClaudeSwapAccountProjection.accountSnapshots( + from: ClaudeSwapAccountList( + activeAccountNumber: 2, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "one@example.com", + isActive: false, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 20, resetsAt: nil), + sevenDay: nil), + ClaudeSwapAccountRow( + number: 2, + email: "private@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 37, resetsAt: nil), + sevenDay: nil), + ]), + now: now) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-swap-active-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + #expect(entry.primary?.usedPercent == 37) + #expect(entry.quotaOwnerKey == "claude-swap:2") + #expect(entry.quotaOwnerKey?.contains("private@example.com") == false) + } + @Test func `widget snapshot preserves raw Codex windows for timeline projection`() async throws { let suite = "UsageStoreWidgetSnapshotTests-codex-weekly-cap" diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift index f879df45c..38548db09 100644 --- a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Foundation import Testing @testable import CodexBar @@ -117,6 +118,56 @@ struct UserFacingLocalizationCoverageTests { "Raw user-facing localization markers remain:\n\(violations.joined(separator: "\n"))") } + @Test + func `provider detail localization preserves technical identifiers`() throws { + let details = try [ + ProviderDetailSection( + title: "Usage", + rows: [ + .init(label: "Balance", value: "$12.34"), + .init(label: "Top model", value: "deepseek-v4-flash"), + ], + chart: .init( + kind: .bars, + title: "Usage", + unit: "tokens", + points: [.init(label: "2026-08-20", value: 42)])), + ] + + let localized = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + UsageMenuCardView.Model.localizedProviderDetails(details, provider: .deepseek) + } + + let section = try #require(localized.first) + #expect(section.title == "用量") + #expect(section.rows.map(\.label) == ["余额", "最常用模型"]) + #expect(section.rows.map(\.value) == ["$12.34", "deepseek-v4-flash"]) + #expect(section.chart?.title == "用量") + #expect(section.chart?.unit == "token") + #expect(section.chart?.points.first?.label == "2026-08-20") + } + + @Test + func `kiro cap phrases localize of prefixes`() throws { + let details = try [ + ProviderDetailSection( + title: "Usage", + rows: [ + .init(label: "Overage usage", value: "3603.49 credits", secondaryValue: "of 10000"), + .init(label: "Overage cost", value: "$144.14", secondaryValue: "of $400.00"), + ]), + ] + + let localized = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + UsageMenuCardView.Model.localizedProviderDetails(details, provider: .kiro) + } + + let section = try #require(localized.first) + #expect(section.rows.map(\.label) == ["超额用量", "超额费用"]) + #expect(section.rows.map(\.value) == ["3603.49 额度", "$144.14"]) + #expect(section.rows.map(\.secondaryValue) == ["/ 10000", "/ $400.00"]) + } + @Test func `spend dashboard model breakdown state stays precise and localized`() throws { let root = URL(fileURLWithPath: #filePath) diff --git a/Tests/CodexBarTests/V026SnapshotsCodableTests.swift b/Tests/CodexBarTests/V026SnapshotsCodableTests.swift index 24d5ac02b..13c58c730 100644 --- a/Tests/CodexBarTests/V026SnapshotsCodableTests.swift +++ b/Tests/CodexBarTests/V026SnapshotsCodableTests.swift @@ -123,7 +123,13 @@ struct V026SnapshotsCodableTests { bonusUsed: 45, bonusTotal: 200, bonusExpiryDays: 19, - resetsAt: Date(timeIntervalSince1970: 1_700_000_000)) + resetsAt: Date(timeIntervalSince1970: 1_700_000_000), + overageCreditsUsed: 125, + estimatedOverageCostUSD: nil, + overageCreditsCap: 500, + overageCharges: 18.75, + overageChargeLimit: 75, + overageCurrencyCode: "EUR") let withoutBonus = SyncKiroCredits( planName: nil, creditsUsed: 0, @@ -140,6 +146,29 @@ struct V026SnapshotsCodableTests { } } + @Test + func `Kiro credits: legacy overage payload decodes richer limit fields as nil`() throws { + let json = """ + { + "planName": "Pro", + "creditsUsed": 1000, + "creditsTotal": 1000, + "creditsPercent": 100, + "overageCreditsUsed": 25, + "estimatedOverageCostUSD": 2.5 + } + """ + + let decoded = try Self.decoder.decode(SyncKiroCredits.self, from: Data(json.utf8)) + + #expect(decoded.overageCreditsUsed == 25) + #expect(decoded.estimatedOverageCostUSD == 2.5) + #expect(decoded.overageCreditsCap == nil) + #expect(decoded.overageCharges == nil) + #expect(decoded.overageChargeLimit == nil) + #expect(decoded.overageCurrencyCode == nil) + } + // MARK: - SyncBedrockCost @Test @@ -268,7 +297,10 @@ struct V026SnapshotsCodableTests { ]), kiroCredits: SyncKiroCredits( planName: "Pro", creditsUsed: 1, creditsTotal: 2, creditsPercent: 50, - bonusUsed: nil, bonusTotal: nil, bonusExpiryDays: nil, resetsAt: nil), + bonusUsed: nil, bonusTotal: nil, bonusExpiryDays: nil, resetsAt: now, + overageCreditsUsed: 10, estimatedOverageCostUSD: nil, + overageCreditsCap: 100, overageCharges: 1.5, + overageChargeLimit: 15, overageCurrencyCode: "GBP"), bedrockCost: SyncBedrockCost( monthlySpendUSD: 1, monthlyBudgetUSD: 2, inputTokens: nil, outputTokens: nil, region: "us-east-1", budgetUsedPercent: 50, updatedAt: now), @@ -282,6 +314,11 @@ struct V026SnapshotsCodableTests { #expect(decoded.openAIAPIDashboard != nil) #expect(decoded.zaiHourlyUsage != nil) #expect(decoded.kiroCredits?.planName == "Pro") + #expect(decoded.kiroCredits?.overageCreditsCap == 100) + #expect(decoded.kiroCredits?.overageCharges == 1.5) + #expect(decoded.kiroCredits?.overageChargeLimit == 15) + #expect(decoded.kiroCredits?.overageCurrencyCode == "GBP") + #expect(decoded.kiroCredits?.resetsAt == now) #expect(decoded.bedrockCost?.region == "us-east-1") #expect(decoded.moonshotBalance?.balanceCurrency == "USD") #expect(decoded.antigravityAccounts?.accounts.first?.email == "a@b.test") diff --git a/Tests/CodexBarTests/XAICostUsageMappingTests.swift b/Tests/CodexBarTests/XAICostUsageMappingTests.swift new file mode 100644 index 000000000..d4185b0f9 --- /dev/null +++ b/Tests/CodexBarTests/XAICostUsageMappingTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct XAICostUsageMappingTests { + @Test + func `daily spend chart becomes vendor-metered catalog input`() throws { + let snapshot = try self.snapshot( + points: [("2026-08-17", 0.50), ("2026-08-18", 1.26)], + confidence: .exact) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30)) + #expect(mapped.last30DaysCostUSD == 1.76) + #expect(mapped.last30DaysTokens == nil) + #expect(mapped.sessionCostUSD == nil) + #expect(mapped.historyDays == 30) + #expect(mapped.costProvenance == .vendorMetered) + #expect(mapped.daily.map(\.date) == ["2026-08-17", "2026-08-18"]) + #expect(mapped.daily.map(\.costUSD) == [0.50, 1.26]) + #expect(mapped.historyCoverageIsEstablished) + } + + @Test + func `today is the UTC day of updatedAt not the newest point`() throws { + let snapshot = try self.snapshot( + points: [("2027-01-14", 0.50), ("2027-01-15", 1.26)], + confidence: .exact, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 365)) + #expect(mapped.sessionCostUSD == 1.26) + #expect(mapped.historyDays == 30) + } + + @Test + func `requested 365-day window stays a 30-day source`() throws { + let snapshot = try self.snapshot( + points: [("2026-08-18", 1.0)], + confidence: .exact) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 365)) + #expect(mapped.historyDays == 30) + #expect(mapped.last30DaysCostUSD == 1.0) + } + + @Test + func `prepaid balance alone is not spend`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 10, + limit: 0, + currencyCode: "USD", + period: "Prepaid credits", + updatedAt: Date()), + updatedAt: Date()) + #expect(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30) == nil) + #expect(XAICostUsageMapping.isAnalyticsUnavailable(snapshot)) + } + + @Test + func `empty successful chart is confirmed empty not unavailable`() throws { + let snapshot = try self.snapshot(points: [], confidence: .exact) + #expect(XAICostUsageMapping.isAnalyticsUnavailable(snapshot) == false) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30)) + #expect(mapped.daily.isEmpty) + #expect(mapped.last30DaysCostUSD == 0) + #expect(mapped.sessionCostUSD == nil) + } + + @Test + func `partial history stays estimated`() throws { + let snapshot = try self.snapshot(points: [("2026-08-18", 1.0)], confidence: .estimated) + let mapped = try #require(XAICostUsageMapping.tokenSnapshot(from: snapshot, historyDays: 30)) + #expect(mapped.historyCoverageIsEstablished == false) + #expect(mapped.historyLabel == "Last 30 days (partial)") + } + + private func snapshot( + points: [(String, Double)], + confidence: UsageDataConfidence, + updatedAt: Date = Date(timeIntervalSince1970: 1_800_000_000)) throws -> UsageSnapshot + { + let chart = try ProviderDetailSection.Chart( + kind: .bars, + title: "Daily spend", + unit: "USD", + points: points.map { try ProviderDetailSection.Chart.Point(label: $0.0, value: $0.1) }) + let details = try [ProviderDetailSection( + title: "Billing summary", + rows: [ProviderDetailSection.Row(label: "Last 30 days", value: "$1.76")], + chart: chart)] + return UsageSnapshot( + primary: nil, + secondary: nil, + details: details, + updatedAt: updatedAt, + dataConfidence: confidence) + } +} diff --git a/Tests/CodexBarTests/XAIProviderTests.swift b/Tests/CodexBarTests/XAIProviderTests.swift index 63bc05852..e5fe69a83 100644 --- a/Tests/CodexBarTests/XAIProviderTests.swift +++ b/Tests/CodexBarTests/XAIProviderTests.swift @@ -194,6 +194,8 @@ struct XAIProviderTests { #expect(usage.balanceUSD == 10.0) #expect(usage.daily.isEmpty) #expect(!usage.limitReached) + #expect(!usage.historyAvailable) + #expect(usage.toUsageSnapshot().details.first?.chart == nil) } @Test @@ -230,6 +232,7 @@ struct XAIProviderTests { let usage = try await Self.fetch(usageBody: #"{"object":"list"}"#) #expect(usage.balanceUSD == 10.0) #expect(usage.daily.isEmpty) + #expect(!usage.historyAvailable) } @Test @@ -261,6 +264,7 @@ struct XAIProviderTests { let usage = try await Self.fetch(usageBody: body) #expect(usage.balanceUSD == 10.0) #expect(usage.daily.isEmpty) + #expect(!usage.historyAvailable) } } @@ -392,13 +396,32 @@ struct XAIProviderTests { } @Test - func `empty history yields no cost history snapshot`() { + func `successful empty history is confirmed zero rather than unavailable`() async throws { + let usage = try await Self.fetch(usageBody: #"{"timeSeries":[],"limitReached":false}"#) + #expect(usage.historyAvailable) + #expect(usage.daily.isEmpty) + #expect(usage.toUsageSnapshot().details.first?.chart?.points.isEmpty == true) + let cost = try #require(usage.costHistorySnapshot()) + #expect(cost.daily.isEmpty) + #expect(cost.last30DaysCostUSD == 0) + #expect(cost.sessionCostUSD == nil) + } + + @Test + func `legacy empty snapshot decodes as unavailable`() throws { let usage = XAIUsageSnapshot( balanceUSD: 1, daily: [], updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) - #expect(usage.costHistorySnapshot() == nil) - #expect(usage.toUsageSnapshot().providerCost?.used == 1) + let encoded = try JSONEncoder().encode(usage) + var object = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object.removeValue(forKey: "historyAvailable") + let legacy = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(XAIUsageSnapshot.self, from: legacy) + + #expect(!decoded.historyAvailable) + #expect(decoded.costHistorySnapshot() == nil) } @Test @@ -411,6 +434,7 @@ struct XAIProviderTests { let encoded = try JSONEncoder().encode(usage.toUsageSnapshot()) let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) #expect(decoded.xaiUsage == usage) + #expect(decoded.xaiUsage?.historyAvailable == true) #expect(decoded.details == usage.toUsageSnapshot().details) #expect(decoded.providerCost?.used == 7.36) } @@ -424,7 +448,7 @@ struct XAIProviderTests { #expect(descriptor.metadata.cliName == "xai") #expect(descriptor.metadata.defaultEnabled == false) #expect(!descriptor.metadata.supportsCredits) - #expect(!descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.tokenCost.supportsTokenCost) #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) #expect(descriptor.cli.aliases.isEmpty) diff --git a/Tests/CodexBarTests/ZaiMenuCardTests.swift b/Tests/CodexBarTests/ZaiMenuCardTests.swift index c95e695f9..1cd65c93d 100644 --- a/Tests/CodexBarTests/ZaiMenuCardTests.swift +++ b/Tests/CodexBarTests/ZaiMenuCardTests.swift @@ -89,6 +89,97 @@ struct ZaiMenuCardTests { #expect(mcp.secondaryValue == "100 limit · 50 remaining") } + @MainActor + @Test + func `model localizes zai usage sections in simplified chinese`() throws { + let model = try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + try Self.costSummaryModel(style: .inlineSummary) + } + + #expect(model.providerDetails.map(\.title) == ["配额详情", "每小时 token", "每日 token"]) + #expect(model.providerDetails[0].rows[0].label == "Token 配额") + #expect(model.providerDetails[0].rows[0].value == "已使用 3%") + #expect(model.providerDetails[1].rows[0].label == "GLM-5.3") + #expect(model.providerDetails[1].chart?.title == "每小时 token") + #expect(model.providerDetails[1].chart?.unit == "token") + } + + @Test + func `model localizes zai quota values and periodic reset in simplified chinese`() throws { + let now = Date() + let details = try ProviderDetailSection(title: "Quota details", rows: [ + .init(label: "Token quota", value: "45% used"), + .init(label: "Session token quota", value: "0% used"), + .init(label: "MCP quota", value: "6.4% used", secondaryValue: "1000 limit · 936 remaining"), + .init(label: "Credit quota", value: "12% used", secondaryValue: "1000 limit"), + .init(label: "Session credit quota", value: "13% used", secondaryValue: "936 remaining"), + .init(label: "Quota rate", value: "Peak", secondaryValue: "off-peak in 2h 30m"), + .init(label: "Quota rate", value: "Off-peak", secondaryValue: "peak now"), + .init(label: "search-prime", value: "64"), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: "5-hour"), + secondary: RateWindow( + usedPercent: 45, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: nil), + extraRateWindows: [NamedRateWindow( + id: "zai-mcp", + title: "MCP", + window: RateWindow(usedPercent: 6.4, windowMinutes: nil, resetsAt: nil, resetDescription: "MCP"))], + details: [details], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let metadata = try #require(ProviderDefaults.metadata[.zai]) + + let model = CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + UsageMenuCardView.Model.make(.init( + provider: .zai, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + #expect(model.metrics.first?.title == "5 小时") + #expect(model.metrics.first?.resetText == "每 5 小时重置") + let rows = try #require(model.providerDetails.first?.rows) + #expect(rows[0].value == "已使用 45%") + #expect(rows[1].label == "会话 Token 配额") + #expect(rows[1].value == "已使用 0%") + #expect(rows[2].label == "MCP 配额") + #expect(rows[2].value == "已使用 6.4%") + #expect(rows[2].secondaryValue == "上限 1000 · 剩余 936") + #expect(rows[3].label == "额度配额") + #expect(rows[3].secondaryValue == "上限 1000") + #expect(rows[4].label == "会话额度配额") + #expect(rows[4].secondaryValue == "剩余 936") + #expect(rows[5].label == "配额费率") + #expect(rows[5].value == "高峰") + #expect(rows[5].secondaryValue == "非高峰 2 小时 30 分钟后") + #expect(rows[6].value == "非高峰") + #expect(rows[6].secondaryValue == "高峰 现在") + #expect(rows[7].label == "search-prime") + } + @MainActor private static func costSummaryModel(style: CostSummaryDisplayStyle) throws -> UsageMenuCardView.Model { let settings = testSettingsStore(suiteName: "ZaiMenuCardTests-cost-summary-\(style.rawValue)") diff --git a/TestsLinux/CLICardsClaudeSwapTests.swift b/TestsLinux/CLICardsClaudeSwapTests.swift index 708206f56..7ef4d7573 100644 --- a/TestsLinux/CLICardsClaudeSwapTests.swift +++ b/TestsLinux/CLICardsClaudeSwapTests.swift @@ -269,7 +269,7 @@ struct CLICardsClaudeSwapTests { "Token expired. Switch to this account in claude-swap to refresh it.", "claude-swap could not read the active account's Keychain entry.", "No stored credentials for this account slot.", - "Usage fetch failed.", + "Polling deferred until a limit resets.", "Unrecognized claude-swap status: future_status", "No usage windows reported.", ]) @@ -277,7 +277,7 @@ struct CLICardsClaudeSwapTests { @Test func `active sentinel account remains active and metrics less in full and brief cards`() async { - let problem = "Usage fetch failed." + let problem = "Polling deferred until a limit resets." let output = await CLIClaudeSwapCards.fetch( eligible: true, executablePath: "/fake/cswap", @@ -313,6 +313,42 @@ struct CLICardsClaudeSwapTests { #expect(rows.first?.usedPercent == nil) } + @Test + func `unavailable at limit windows keep metrics and name the exhausted window`() async { + let reset = Date(timeIntervalSince1970: 1_700_003_600) + let output = await CLIClaudeSwapCards.fetch( + eligible: true, + executablePath: "/fake/cswap", + renderOptions: self.renderOptions(), + ambientFetch: { self.ambientOutput(failed: true) }, + accountListReader: { _ in + ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "limited@example.com", + isActive: true, + usageStatus: .unavailable, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)), + self.row(number: 2), + ]) + }) + + #expect(output.exitCode == .success) + let activeCard = output.cards.first + #expect(activeCard?.accountLine == "limited@example.com") + #expect(activeCard?.isActive == true) + #expect(activeCard?.accountProblem == + "Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1h.") + #expect(activeCard?.metrics.isEmpty == false) + #expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true) + #expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true) + + let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? []) + #expect(rows.first?.accountProblem?.contains("Session limit reached") == true) + #expect(rows.first?.usedPercent == 100) + } + @Test func `blank executable path preserves ambient output and fails distinctly`() async { let ambient = self.ambientOutput() diff --git a/TestsPlugin/ZaiPluginBalanceTests.swift b/TestsPlugin/ZaiPluginBalanceTests.swift new file mode 100644 index 000000000..de7c8312c --- /dev/null +++ b/TestsPlugin/ZaiPluginBalanceTests.swift @@ -0,0 +1,169 @@ +import CodexBarCore +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing + +/// BigModel CN pay-as-you-go account balance, surfaced by the bundled zai plugin +/// as a best-effort detail row (endpoint verified against the live console API: +/// `GET www.bigmodel.cn/api/biz/account/query-customer-account-report`, which +/// accepts both `Bearer ` and raw-key Authorization). +struct ZaiPluginBalanceTests { + @Test + func `bigmodel CN snapshot renders account balance row`() async throws { + let recorder = BalanceRequestRecorder() + let snapshot = try await Self.fetch( + region: "bigmodel-cn", + balanceBody: """ + { + "code": 200, + "msg": "操作成功", + "success": true, + "data": { + "balance": 42.5, + "availableBalance": 40.0, + "rechargeAmount": 100.0, + "giveAmount": 20.0, + "totalSpendAmount": 77.5, + "frozenBalance": 2.5 + } + } + """, + recorder: recorder) + + // availableBalance wins over balance; the secondary line summarizes spend provenance + #expect(snapshot.detailRow(label: "Account balance")?.value == "¥40.00") + #expect( + snapshot.detailRow(label: "Account balance")?.secondaryValue + == "recharged ¥100.00 · granted ¥20.00 · spent ¥77.50") + let balanceRequest = try #require(await recorder.requests.first { $0.url?.host == "www.bigmodel.cn" }) + #expect(balanceRequest.url?.path == "/api/biz/account/query-customer-account-report") + // Optional lookup must be bounded well below the fetch deadline (review P1). + #expect(balanceRequest.timeoutInterval == 5) + } + + @Test + func `null availableBalance falls back to balance and hides null secondary fields`() async throws { + // Number(null) is 0 — without an explicit null guard the row would read ¥0.00. + let snapshot = try await Self.fetch( + region: "bigmodel-cn", + balanceBody: """ + { + "code": 200, + "success": true, + "data": { + "balance": 42.5, + "availableBalance": null, + "rechargeAmount": null, + "giveAmount": 5.0, + "totalSpendAmount": null + } + } + """) + + #expect(snapshot.detailRow(label: "Account balance")?.value == "¥42.50") + #expect(snapshot.detailRow(label: "Account balance")?.secondaryValue == "granted ¥5.00") + } + + @Test + func `region-aware validation rejects invalid balance override`() { + #expect(throws: ZaiSettingsError.self) { + try ZaiSettingsReader.validateEndpointOverrides( + region: .bigmodelCN, + environment: [ZaiSettingsReader.balanceURLKey: "http://insecure.test/report"]) + } + #expect(throws: ZaiSettingsError.self) { + try ZaiSettingsReader.validateEndpointOverrides( + environment: [ZaiSettingsReader.balanceURLKey: "http://insecure.test/report"]) + } + } + + @Test + func `balance endpoint failure keeps quota snapshot intact`() async throws { + let snapshot = try await Self.fetch(region: "bigmodel-cn", balanceBody: "{}", balanceStatus: 500) + + #expect(snapshot.primary?.usedPercent == 42) + #expect(snapshot.detailRow(label: "Account balance") == nil) + } + + @Test + func `global region skips the balance request entirely`() async throws { + let recorder = BalanceRequestRecorder() + let snapshot = try await Self.fetch(region: "global", balanceBody: "{}", recorder: recorder) + + #expect(snapshot.primary?.usedPercent == 42) + let balanceHostRequests = await recorder.requests.filter { $0.url?.host == "www.bigmodel.cn" } + #expect(balanceHostRequests.isEmpty) + } + + @Test + func `router resolves CN balance default, explicit override, and nil for global`() { + #expect( + ZaiEndpointRouter.resolveBalanceURL(region: .bigmodelCN, environment: [:]) + == ZaiAPIRegion.bigmodelCN.balanceURL) + #expect(ZaiEndpointRouter.resolveBalanceURL(region: .global, environment: [:]) == nil) + let overridden = ZaiEndpointRouter.resolveBalanceURL( + region: .bigmodelCN, + environment: [ZaiSettingsReader.balanceURLKey: "https://balance-proxy.test/report"]) + #expect(overridden?.absoluteString == "https://balance-proxy.test/report") + } + + // MARK: - Fixtures + + private static func fetch( + region: String, + balanceBody: String, + balanceStatus: Int = 200, + recorder: BalanceRequestRecorder? = nil) async throws -> UsageSnapshot + { + let runtime = try ProviderPluginRuntime( + bundledPlugin: "zai", + transport: ProviderHTTPTransportHandler { request in + if let recorder { + await recorder.append(request) + } + let body = request.url?.host == "www.bigmodel.cn" ? balanceBody : Self.quotaFixture + let status = request.url?.host == "www.bigmodel.cn" ? balanceStatus : 200 + return try Self.response(request: request, body: body, status: status) + }) + return try await runtime.fetchUsage( + settings: [ + "Z_AI_REGION": region, + "Z_AI_USAGE_SCOPE": "personal", + "Z_AI_QUOTA_ENDPOINT": "https://open.bigmodel.cn/api/monitor/usage/quota/limit", + "Z_AI_MODEL_USAGE_ENDPOINT": "https://open.bigmodel.cn/api/monitor/usage/model-usage", + ], + secrets: [ZaiSettingsReader.apiTokenKey: "fixture-key"]) + } + + private static let quotaFixture = """ + { + "code": 200, + "success": true, + "data": { + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 5, "number": 300, "percentage": 42, + "usage": 1000, "remaining": 580, "nextResetTime": 1756000000 } + ] + } + } + """ + + private static func response(request: URLRequest, body: String, status: Int) throws -> (Data, HTTPURLResponse) { + let response = try #require(HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } +} + +private actor BalanceRequestRecorder { + private(set) var requests: [URLRequest] = [] + + func append(_ request: URLRequest) { + self.requests.append(request) + } +} diff --git a/docs/antigravity.md b/docs/antigravity.md index 6d8efdb81..8b34ebf4c 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -238,6 +238,9 @@ shared OAuth file can still be used as a fallback credential source. right after a weekly reset. Provider details is the diagnostic surface and always lists every family, the same principle it already applies to cost data. The filter is display-only: the snapshot, CLI output, and menu-bar ranking still see every window, and menu-bar selection ranks by highest used, so an untouched family never wins. +- The dashboard-v1 payload keeps every family for its script clients and marks the lanes of an untouched family with + `idle` instead. The `quotakit serve` web UI skips those rows, so the web card matches the menu without repeating + the family rule in JavaScript. See `docs/dashboard-api.md`. ## Constraints - Internal protocol; fields may change. diff --git a/docs/claude-multi-account-and-status-items.md b/docs/claude-multi-account-and-status-items.md index 0c11bcb3b..c9ae72462 100644 --- a/docs/claude-multi-account-and-status-items.md +++ b/docs/claude-multi-account-and-status-items.md @@ -75,9 +75,12 @@ envelope. CodexBar does not need - Require `schemaVersion == 1`; reject unknown versions and partial top-level shapes. - Bound runtime and stdout, terminate on timeout, and retain the last successful snapshot with a stale marker. - Parse only slot number, active state, usage status, 5-hour/7-day percentages, optional `usage.scoped` display names - and percentages, and reset timestamps. Ignore malformed or unknown scoped rows without discarding valid account-wide - windows. -- Treat email as display-only sensitive data. Never log or persist it. Respect Hide Personal Info. + and percentages, reset timestamps, display-only `organizationName` (always present, may be empty), and optional + display-only `alias` when non-empty. Ignore malformed or unknown scoped rows without discarding valid account-wide + windows. Unknown extra JSON fields remain ignored. Empty `organizationName` is not an error; `alias` is not required. +- Treat email, organization name, and alias as display-only. Never log or persist them. Respect Hide Personal Info. + When two or more slots share an email, disambiguate with `email · organizationName` or `email · Account N`; a + user-chosen alias wins. Unique emails stay email-only. - Use the source-issued numeric slot for identity (`claude-swap:`), not email or credential-derived values. - CodexBar never reads `claude-swap` storage, Claude Code storage, environment credentials, or Keychain entries. The subprocess remains solely responsible for its own credential access. The adapter copies only allow-listed diff --git a/docs/claude.md b/docs/claude.md index 7b4bac0ae..f4b3e8da4 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -131,8 +131,11 @@ The accepted multi-account design in [`cswap`](https://github.com/realiti4/claude-swap) executable (for example `~/.local/bin/cswap`). - Behavior: on each Claude refresh, QuotaKit runs `cswap --list --json` independently of the ambient Claude fetch (no shell, fixed arguments, bounded runtime and output), requires `schemaVersion == 1`, and parses only slot number, - active state, usage status, email (display only), the 5-hour/7-day windows, and optional display-only model-scoped - weekly windows from `usage.scoped`. + active state, usage status, email (display only), display-only `organizationName` (always present, may be empty), + optional display-only `alias` when non-empty, the 5-hour/7-day windows, and optional display-only model-scoped + weekly windows from `usage.scoped`. Identity stays `claude-swap:`; organization name and alias are never + used as identity. When two or more slots share an email, cards append ` · organizationName` or ` · Account N`; + a user-chosen cswap alias replaces that label. Unique emails stay email-only. - Display: when claude-swap reports more than one account, the Claude menu and `quotakit cards` show one card per account (active account first, then numeric slot) instead of ambient/token-account Claude cards. With four or more accounts the app menu switches to a compact layout (`AccountMenuLayoutPlanner`): the active account keeps its full @@ -160,8 +163,12 @@ The accepted multi-account design in cards, a list failure retains the current ambient output, adds a distinct `Claude (claude-swap)` footer entry, and exits non-zero. - Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`, - `unavailable`, and unknown future values) render as per-account notes instead of usage bars in both full and brief - cards. Active rows are marked `[active]`; no claude-swap row infers a plan badge. + and unknown future values) render as per-account notes instead of usage bars in both full and brief cards. When + `unavailable` means claude-swap deferred polling because a window is at 100%, QuotaKit keeps that slot's last + projected usage bars and names the exhausted window (5-hour session, 7-day weekly, and/or a scoped model such as + Fable) plus its reset time — not "Usage fetch failed." A first refresh that is already `unavailable` with no + retained windows still notes that polling is deferred. Active rows are marked `[active]`; no claude-swap row infers + a plan badge. - Switching: an inactive account with usable source credentials shows “Switch Account…”. Clicking it runs exactly `cswap --switch-to --json`, validates the versioned result and requested slot, then refreshes both ambient Claude usage and every claude-swap account card. Switches are serialized; no automatic switching occurs. While diff --git a/docs/cli.md b/docs/cli.md index 13e204389..8935fed4c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -129,7 +129,7 @@ See `docs/configuration.md` for the schema. - `web`: web-only where that provider exposes an explicit web source; no CLI/API fallback. Browser import is macOS-only, while supported providers can use configured manual cookies on Linux. - `cli`: CLI/local-helper source where the provider exposes one (for example Codex RPC/PTy, Claude PTY, Kilo CLI fallback, Kiro CLI, local probes). - `oauth`: OAuth-backed source where supported (Codex, Claude, Vertex AI). - - `api`: API-key/token flow when the provider supports it (OpenAI, Claude Admin API, z.ai, Gemini, Alibaba, Copilot, Kilo, Kimi, MiniMax, Ollama, Warp, OpenRouter, ElevenLabs, Deepgram, Synthetic, DeepSeek, Moonshot, Doubao, Codebuff, Crof, Venice, AWS Bedrock). + - `api`: API-key/token flow when the provider supports it (OpenAI, Claude Admin API, z.ai, Gemini, Alibaba, Copilot, OpenCode Go, Kilo, Kimi, MiniMax, Ollama, Warp, OpenRouter, ElevenLabs, Deepgram, Synthetic, DeepSeek, DeepInfra, Moonshot, Doubao, Codebuff, Crof, Venice, AWS Bedrock). - Output `source` reflects the strategy actually used (`openai-web`, `web`, `oauth`, `api`, `local`, `cli`, or provider CLI label). - Codex web: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown). - `--web-timeout ` (default: 60) @@ -140,7 +140,8 @@ See `docs/configuration.md` for the schema. command delegates authentication to Claude Code; the app keeps its stricter prompt-free background availability gate for scheduled refreshes. - Command Code web: commandcode.ai browser session cookies on macOS, or a configured manual cookie on Linux, for monthly credit usage. - - OpenCode Go auto: local SQLite usage on macOS and Linux, with optional manual-cookie web enrichment. + - OpenCode Go auto: local SQLite cost history on macOS and Linux with API usage-window enrichment when + `OPENCODE_API_KEY` is configured, plus legacy manual-cookie web fallback. - Kilo auto: app.kilo.ai API first, then CLI auth fallback (`~/.local/share/kilo/auth.json`) on missing/unauthorized API credentials. - Linux: browser-backed `auto`/`web` modes are not supported; local sources and configured manual-cookie paths remain available where documented. - Global flags: `-h/--help`, `-V/--version`, `-v/--verbose`, `--no-color`, `--log-level `, `--json-output`, `--json-only`. diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md index 4b96bb7be..8769157c2 100644 --- a/docs/dashboard-api.md +++ b/docs/dashboard-api.md @@ -254,7 +254,14 @@ while leaving the ambient Claude row intact. - `providers[].source`: Source used for the provider data. - `providers[].status`: Provider service status when available (`level`: `ok` | `warning` | `critical` | `unknown`). - `providers[].identity`: Account email in the selected identity mode and plan label, or `null`. -- `providers[].windows`: Session, weekly, tertiary, or provider-specific rate windows. +- `providers[].windows`: Session, weekly, tertiary, or provider-specific rate windows. Antigravity drops its + duplicated representative rows here and emits one row per quota bucket instead. +- `providers[].windows[].idle`: `true` when a display client should skip the row, because the window belongs to a + model family that reports no usage. The key appears only when it is `true`, so a payload with no idle window keeps + its previous shape. This is an additive schema-v1 extension. A client that wants every window, such as a script or + an adapter, ignores the key and keeps the row. The built-in web UI drops these rows so the page matches the app + menu, which hides an untouched Antigravity model family. Only the producer can set this: a zero `usedPercent` also + stands for a lane whose usage the provider never reported, and the payload does not carry that distinction. - `providers[].credits`: Remaining credits or balance when available. - `providers[].cost`: Local cost data when available. - `providers[].display`: UI hints for ordering and coloring. diff --git a/docs/grok.md b/docs/grok.md index c711e6db5..80bbba7d9 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -206,9 +206,14 @@ Each session directory contains `signals.json` with fields like: ``` QuotaKit aggregates these into a `GrokLocalSessionSummary` (session count, total -tokens, last session time, primary model) and exposes it for diagnostics even when +tokens, last session time, primary model, per-day token buckets) and exposes it for diagnostics even when the RPC path is unavailable. +Those local daily token buckets also feed the shared Usage & Spend catalog so an +enabled Grok subscription is counted instead of omitted. SuperGrok/X Premium+ +credits remain a quota window on the usage bar; they are never converted into +dollars. + ## Status xAI has not exposed a Statuspage-style status feed yet. The "View Status" link diff --git a/docs/kiro.md b/docs/kiro.md index 61056986c..65fc6a95a 100644 --- a/docs/kiro.md +++ b/docs/kiro.md @@ -1,9 +1,10 @@ --- -summary: "Kiro provider data sources: CLI-based usage via kiro-cli /usage command." +summary: "Kiro provider data sources: CLI-based usage via kiro-cli /usage, enriched with GetUsageLimits for overage." read_when: - Debugging Kiro usage parsing - Updating kiro-cli command behavior - Reviewing Kiro credit window mapping + - Working on Kiro overage credits --- # Kiro provider @@ -12,7 +13,7 @@ Kiro uses the AWS `kiro-cli` tool to fetch usage data. No browser cookies or OAu ## Data sources -1) **CLI command** (primary and only strategy) +1) **CLI command** (primary) - Command: `kiro-cli chat --no-interactive "/usage"` - Timeout: 20 seconds (idle cutoff after 4 seconds of no output once the CLI starts responding). - CodexBar tries ordinary stdout/stderr pipes first for current Kiro CLI releases. Incomplete or unusable @@ -20,6 +21,20 @@ Kiro uses the AWS `kiro-cli` tool to fetch usage data. No browser cookies or OAu - Requires `kiro-cli` installed and logged in via AWS Builder ID. - Output is ANSI-decorated; CodexBar strips escape sequences before parsing. +2) **`GetUsageLimits` API** (overage enrichment, best effort) + - The CLI report states credits against the plan alone and **omits the overage section entirely for + organization accounts**, so it can never state the overage cap. The API carries the overage allowance + on top of the plan, which is the ceiling an account actually spends against. + - Endpoint: `POST https://codewhisperer.us-east-1.amazonaws.com/`, + header `X-Amz-Target: AmazonCodeWhispererService.GetUsageLimits`, body `{"profileArn": ...}`. + - Credentials come from the CLI's own state, opened **read-only** (the CLI owns the token and its refresh): + `~/Library/Application Support/kiro-cli/data.sqlite3` + - `auth_kv` key `kirocli:odic:token` → `access_token` + - `state` key `api.codewhisperer.profile` → `arn` + - Runs after the CLI probe, so a token the CLI refreshed along the way is already in place. + - Failure is non-fatal: the plan-relative numbers the CLI produced stand. The API path depends on the CLI's + private token store, which a Kiro release can move, whereas the CLI reads only its own published output. + ## Output format (example) ``` @@ -36,16 +51,30 @@ Kiro uses the AWS `kiro-cli` tool to fetch usage data. No browser cookies or OAu ## Snapshot mapping -- **Primary window**: Monthly credits percentage (bar meter). - - `usedPercent`: extracted from `███...█ X%` pattern. - - `resetsAt`: parsed from `resets on MM/DD` (assumes current or next year). +- **Primary window**: Monthly plan credits percentage (bar meter). + - `usedPercent`: extracted from `███...█ X%` pattern, or `planUsed / planLimit` when the API answered. + - `resetsAt`: parsed from `resets on MM/DD` (assumes current or next year), or `nextDateReset` from the API. - **Secondary window**: Bonus credits (when present). - - Parsed from `Bonus credits: X.XX/Y credits used`. + - Parsed from `Bonus credits: X.XX/Y credits used`. Always CLI-sourced. When `GetUsageLimits` includes a + non-empty `bonuses[]` array, QuotaKit keeps the CLI plan gauge instead of treating bonus spend as plan + usage; overage enrichment still applies. - Expiry from `expires in N days`. +- **Extra window** `kiro-overage`: overage credits spent against `overageCapWithPrecision` (API only). This + is a second credit ceiling, not optional extra usage — the plan gauge stays plan-only so a spent plan + still shows remaining overage headroom. The bar reuses the Credits remaining copy + (`N of M credits left`). +- **Provider cost**: `overageCharges` against `overageCap × overageRate` (API only). - **Identity**: - `accountOrganization`: plan name (e.g., "KIRO FREE"). - `loginMethod`: plan name (used for menu display). +### Plan vs overage split + +`currentUsageWithPrecision` is the **total** including overage, so plan usage is +`currentUsage - currentOverages`. Feeding `currentUsage` into the plan gauge would read over 100% and +double-count the same spend in both gauges. Components are validated individually rather than as a sum, so a +negative one cannot hide inside a positive total. + ## Status Kiro does not have a dedicated status page. The "View Status" link opens the AWS Health Dashboard: @@ -55,4 +84,5 @@ Kiro does not have a dedicated status page. The "View Status" link opens the AWS - `Sources/CodexBarCore/Providers/Kiro/KiroProviderDescriptor.swift` - `Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift` +- `Sources/CodexBarCore/Providers/Kiro/KiroUsageLimitsAPI.swift` - `Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift` diff --git a/docs/opencode.md b/docs/opencode.md index bf0efdf2f..eabd80609 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -9,6 +9,8 @@ read_when: ## Data sources - Browser cookies from `opencode.ai`. +- OpenCode Go usage API at `GET https://opencode.ai/zen/go/v1/usage`, authenticated by `OPENCODE_API_KEY` or + `providers[].apiKey`. - OpenCode Go local history from `~/.local/share/opencode/opencode.db` on macOS and Linux. - `POST https://opencode.ai/_server` with server function IDs: - `workspaces` (`def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f`) @@ -29,13 +31,15 @@ read_when: - Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL. - Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.opencode`, source + timestamp). Browser import only runs when the cached cookie fails. -- OpenCode Go unscoped Auto mode tries quota windows and daily cost history derived from local `opencode-go` assistant - costs first, then falls back to web usage when local history is unavailable. Auto stays web-first when a token account, - manual cookie, or workspace override scopes the request, because local history is device-wide. +- OpenCode Go unscoped Auto mode tries daily cost history derived from local `opencode-go` assistant costs first, + overlays authoritative API windows when an API key is configured, then falls back through the API and legacy web + sources when local history is unavailable. Auto stays web-first when a token account, manual cookie, or workspace + override scopes the request, because local history is device-wide. - The local monthly window is an estimate anchored at the earliest local row and can drift from the real billing - cycle. When a cached or manual session cookie is available, the local strategy overlays the server-reported - rolling/weekly/monthly percentages and reset countdowns (plus Zen balance) onto the local snapshot, keeping the - local daily cost history. This path never triggers a fresh browser import. + cycle. The local strategy prefers API-reported rolling/weekly/monthly percentages and reset timestamps. When no API + key is configured, a cached or manual session cookie can still overlay the legacy web values (plus Zen balance). + Both paths keep local daily cost history and never trigger a fresh browser import. When no authoritative overlay is + available, the menu and text CLI label the quota as estimated, and JSON includes `dataConfidence: "estimated"`. - OpenCode Go cost history chart: `opencode.ai` has no daily-granularity endpoint, so per-day cost/request buckets come from local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. Successful web usage remains workspace-scoped and is never blended with device-wide local costs, so it does not show cost history. diff --git a/docs/providers.md b/docs/providers.md index f7237496c..b9584cb7c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -55,7 +55,7 @@ complete when the available scan window covers fewer days. | Antigravity | Local LSP/HTTP probe (`local`). | | Cursor | Web API via cookies → legacy stored session → Cursor.app local auth (`web`). | | OpenCode | Web dashboard via cookies (`web`). | -| OpenCode Go | Unscoped Auto: local SQLite usage (`local`) → web dashboard (`web`). Scoped Auto (selected account/manual cookie/workspace): web → local. Explicit Web: web only. | +| OpenCode Go | Unscoped Auto: local SQLite cost history with API overlay (`local+api`) → usage API (`api`) → web dashboard (`web`). Scoped Auto (selected account/manual cookie/workspace): web → local → API. Explicit API/Web: selected source only. | | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | | Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | | Qwen Cloud | Qwen Cloud 5-hour/weekly Token Plan APIs via browser or manual cookies (`web`). | @@ -214,12 +214,15 @@ complete when the available scan window covers fewer days. - Details: `docs/opencode.md`. ## OpenCode Go +- Preferred usage source: `GET https://opencode.ai/zen/go/v1/usage` with an API key from Settings, + `providers[].apiKey`, or `OPENCODE_API_KEY`. - Web dashboard via browser or manual cookies (`opencode.ai`). -- Unscoped Auto mode prefers local usage from `~/.local/share/opencode/opencode.db` on macOS and Linux, then falls back - to web when local history is unavailable. +- Unscoped Auto mode prefers local cost history from `~/.local/share/opencode/opencode.db` on macOS and Linux, + enriches it with API quota windows when configured, then falls back to standalone API and legacy web sources. - Auto mode stays web-first for selected token accounts, manual cookies, and workspace overrides; explicit Web mode does not include local fallback. -- Uses the workspace Go page/server data for rolling 5-hour, weekly, and optional monthly usage windows. +- Uses the public usage API for rolling 5-hour, weekly, and monthly usage windows, with the workspace Go page/server + data retained as a compatibility fallback. - Optional workspace ID comes from `~/.quotakit/config.json` (`providers[].workspaceID`) or `CODEXBAR_OPENCODEGO_WORKSPACE_ID`. - Status: none yet. - Details: `docs/opencode.md`. diff --git a/docs/xai.md b/docs/xai.md index 0e77f1a19..a86e8eb01 100644 --- a/docs/xai.md +++ b/docs/xai.md @@ -70,6 +70,9 @@ today/30-day totals. When xAI reports its analytics cardinality cap (`limitReach days (partial)" and the snapshot is marked estimated instead of exact. Prepaid money is not a quota, so no session or weekly meters are synthesized. +The same daily spend series is published into Settings → Usage & Spend and Overview as vendor-metered USD. The prepaid +ledger balance is remaining credit and is never treated as spend. + ## CLI Usage ```bash diff --git a/version.env b/version.env index f349c79e7..a3937ee30 100644 --- a/version.env +++ b/version.env @@ -10,4 +10,4 @@ UPSTREAM_SYNC_DATE=2026-08-08 # Advance this when an upstream sync PR lands. It is independent of shipped # release tracking above, so the monitor does not reopen stale issues while a # merged upstream sync has not yet shipped to users. -UPSTREAM_MONITOR_BASE=453174fe13eebdf403cc0776268eb2b101fd9553 +UPSTREAM_MONITOR_BASE=f74117aeb7a9ee02a78c0f08ca354ff26b2292e0