From 0e5071dbfda1510345c7157462ff9ddc58d8304e Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 3 Oct 2026 22:50:13 +0300 Subject: [PATCH 1/2] feat: show Claude subscription leverage from record-level cost provenance (#200) --- Sources/PokeTokenBar/Core/Localization.swift | 22 +++ Sources/PokeTokenBar/Core/Models.swift | 9 + .../PokeTokenBar/Core/TokenFormatter.swift | 5 + Sources/PokeTokenBar/Core/UsageStore.swift | 39 +++++ Sources/PokeTokenBar/UI/PopoverView.swift | 9 + Sources/PokeTokenBar/UI/SettingsView.swift | 29 ++++ .../LocalizationInterpolationTests.swift | 2 + .../PokeTokenBarTests/PokeTokenBarTests.swift | 33 ++++ Tests/PokeTokenBarTests/UsageStoreTests.swift | 157 ++++++++++++++++++ 9 files changed, 305 insertions(+) diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 7038f0ab4..d1c9e36ac 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -257,6 +257,28 @@ struct L { var menuBarItems: String { t("메뉴바 표시 항목 (복수 선택)", "Menu bar items (multi-select)", "メニューバー表示項目(複数選択)", "Elementos de la barra de menús (selección múltiple)", "Éléments de la barre des menus (sélection multiple)", "Itens da barra de menus (seleção múltipla)", "Elemente der Menüleiste (Mehrfachauswahl)") } var todayTokensShort: String { t("오늘 토큰", "Today's tokens", "本日のトークン", "Tokens de hoy", "Tokens du jour", "Tokens de hoje", "Heutige Tokens") } var todayCost: String { t("오늘 비용 ($)", "Today's cost ($)", "本日のコスト ($)", "Coste de hoy ($)", "Coût du jour ($)", "Custo de hoje ($)", "Heutige Kosten ($)") } + var monthlyPlanPriceLabel: String { + t("Claude 월 구독료 ($)", "Claude monthly plan price ($)", "Claude 月額プラン ($)", "Precio mensual del plan de Claude ($)", "Prix mensuel du forfait Claude ($)", "Preço mensal do plano Claude ($)", "Monatlicher Claude-Planpreis ($)") + } + var monthlyPlanPriceHint: String { + t("선택. Claude Max/Pro/Team 레버리지 행에만 씁니다. 0 이면 행을 숨깁니다.", + "Optional. Used only for the Claude Max/Pro/Team leverage row. Set to 0 to hide it.", + "任意。Claude Max/Pro/Team のレバレッジ行にだけ使います。0 なら行を隠します。", + "Opcional. Solo para la fila de rendimiento de Claude Max/Pro/Team. 0 la oculta.", + "Facultatif. Uniquement pour la ligne de rendement Claude Max/Pro/Team. 0 la masque.", + "Opcional. Só para a linha de rendimento do Claude Max/Pro/Team. 0 esconde a linha.", + "Optional. Nur für die Claude-Max/Pro/Team-Hebelzeile. 0 blendet sie aus.") + } + /// `plan` and `apiEquivalent` already include `$`; `multiplier` already includes `×`. + func subscriptionLeverage(plan: String, apiEquivalent: String, multiplier: String) -> String { + t("Claude 플랜 \(plan)/월 · API 환산 \(apiEquivalent) · \(multiplier)", + "Claude plan \(plan)/mo · API-equiv \(apiEquivalent) · \(multiplier)", + "Claude プラン \(plan)/月 · API換算 \(apiEquivalent) · \(multiplier)", + "Plan de Claude \(plan)/mes · equiv. API \(apiEquivalent) · \(multiplier)", + "Forfait Claude \(plan)/mois · éq. API \(apiEquivalent) · \(multiplier)", + "Plano Claude \(plan)/mês · equiv. API \(apiEquivalent) · \(multiplier)", + "Claude-Plan \(plan)/Monat · API-äquiv. \(apiEquivalent) · \(multiplier)") + } var limitPercent: String { t("한도 %", "Limit %", "上限 %", "Límite %", "Limite %", "Limite %", "Limit %") } var menuLimitColor: String { t("퍼센트 색상", "Percent color", "パーセントの色", "Color del porcentaje", "Couleur du pourcentage", diff --git a/Sources/PokeTokenBar/Core/Models.swift b/Sources/PokeTokenBar/Core/Models.swift index f4984fbd2..5cef8f57f 100644 --- a/Sources/PokeTokenBar/Core/Models.swift +++ b/Sources/PokeTokenBar/Core/Models.swift @@ -290,6 +290,15 @@ struct LimitStatus: Decodable, Sendable { return "\(accountEmail) · \(org)" } + /// Max/Pro/Team pay a flat monthly price, so Claude's per-record `$` reads as API-equivalent + /// value rather than spend. Gates the leverage row only; cost rows keep their bare `$`. + var isFlatRateSubscription: Bool { + switch subscriptionType?.lowercased() { + case "max", "pro", "team": return true + default: return false + } + } + /// rateLimitTier 끝의 배수 토큰("20x"/"5x") 추출 — "_" 로 나눠 숫자+x 형태를 찾는다. /// 배수가 없는 등급("default_claude_pro")은 nil → 등급명만 표시. private static func tierMultiplier(from tier: String) -> String? { diff --git a/Sources/PokeTokenBar/Core/TokenFormatter.swift b/Sources/PokeTokenBar/Core/TokenFormatter.swift index a0115fa68..5fe97f46e 100644 --- a/Sources/PokeTokenBar/Core/TokenFormatter.swift +++ b/Sources/PokeTokenBar/Core/TokenFormatter.swift @@ -34,6 +34,11 @@ enum TokenFormatter { String(format: "$%.2f", usd) } + /// Subscription leverage multiplier: 6.1 → "6.1×", 6.0 → "6×". + static func multiplier(_ value: Double) -> String { + trim(value, decimals: 1) + "×" + } + /// 메뉴바용 짧은 비용 표기: $9.5 / $311 / $1.2K static func costCompact(_ usd: Double) -> String { if usd < 100 { return String(format: "$%.1f", usd) } diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 2b8421548..574afd150 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -181,6 +181,23 @@ final class UsageStore { } } + /// Optional user-entered Claude plan price (USD). 0 = off. No built-in price table — plan + /// prices vary by seat, region and tax. + var monthlyPlanPrice: Double { + didSet { + let sanitized = Self.sanitizedPlanPrice(monthlyPlanPrice) + guard sanitized == monthlyPlanPrice else { + monthlyPlanPrice = sanitized + return + } + defaults.set(monthlyPlanPrice, forKey: "monthlyPlanPrice") + } + } + + static func sanitizedPlanPrice(_ value: Double) -> Double { + value.isFinite ? max(0, value) : 0 + } + static let intervalPresets: [(label: String, value: TimeInterval)] = [ ("수동", 0), ("1분", 60), ("2분", 120), ("5분", 300), ("15분", 900), ] @@ -501,6 +518,27 @@ final class UsageStore { ledger.save(to: defaults) } + /// Claude's calendar-month `$`, with the per-record provenance (`CostCoverage`) the scan + /// merged. Claude's source-reported amounts and price-table estimates are both API-rate + /// figures, so every known amount is API-equivalent; unpriced records stay out of the amount + /// and mark the total partial. Looked up by id like `claudeActiveBlock`: the leverage gate + /// reads the Claude plan, so other providers' costs (other subscriptions, real bills) never + /// enter the numerator. The combined month total still sums every provider. + var claudeMonthAPIEquivalentCost: UsageCost? { + snapshots.first { $0.providerID == "claude_code" && $0.reportsCost }?.monthTotal?.usageCost + } + + /// API-equivalent ÷ plan price for a single Max/Pro/Team login with a price set. With several + /// Claude logins the month is machine-wide while the plan is one account's, so the ratio + /// would mix plans — hidden until usage can be attributed per account. + var subscriptionLeverage: Double? { + guard claudeAccounts.count <= 1, + limits?.isFlatRateSubscription == true, + monthlyPlanPrice > 0, + let cost = claudeMonthAPIEquivalentCost, cost.amount > 0 else { return nil } + return cost.amount / monthlyPlanPrice + } + /// Claude 의 활성 5h 블록 — 5h forecast·"현재 블록" 행은 Claude 공식 한도와 짝이므로 /// providerID 로 명시 조회한다 (전 프로바이더가 블록을 갖게 된 후 first-with-block 은 오매칭). private var claudeActiveBlock: BlockUsage? { @@ -784,6 +822,7 @@ final class UsageStore { // 사용자의 배터리 프로파일은 그대로다. 더 부드러운 쪽은 opt-in(실측 idle CPU 1.8%/5.1%). animationQuality = AnimationQuality(rawValue: d.string(forKey: "animationQuality") ?? "") ?? .powerSaver disableKeychainAccess = d.object(forKey: "disableKeychainAccess") as? Bool ?? false + monthlyPlanPrice = Self.sanitizedPlanPrice(d.object(forKey: "monthlyPlanPrice") as? Double ?? 0) additionalClaudeConfigDirs = d.string(forKey: ClaudeAccountRoots.defaultsKey) ?? "" claudeTrackedAccountMode = ClaudeTrackedAccountMode(storedValue: d.string(forKey: ClaudeTrackedAccountMode.defaultsKey)) armedCandyWindows = Set(d.stringArray(forKey: Self.armedCandyWindowsKey) ?? []) diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 73d4b5c43..6e80396e3 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -211,6 +211,15 @@ struct PopoverView: View { } .padding(.top, 2) } + if let ratio = store.subscriptionLeverage, let cost = store.claudeMonthAPIEquivalentCost { + Text(l.subscriptionLeverage( + plan: TokenFormatter.cost(store.monthlyPlanPrice), + apiEquivalent: cost.text(l), + multiplier: TokenFormatter.multiplier(ratio))) + .font(.caption2) + .foregroundStyle(.tertiary) + .help(cost.explanation(l)) + } MonthDailyTrend(series: store.monthDailyTotals, showsCost: store.showsCost, diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 4b03035a8..3a62c91d9 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -37,6 +37,8 @@ struct SettingsView: View { @FocusState private var customScanFocused: Bool @State private var additionalAccountsDraft = "" @FocusState private var additionalAccountsFocused: Bool + @State private var monthlyPlanPriceDraft: Double = 0 + @FocusState private var monthlyPlanPriceFocused: Bool @FocusState private var sessionKeyFocused: Bool private var l: L { companion.l } @@ -208,6 +210,28 @@ struct SettingsView: View { .labelsHidden().pickerStyle(.menu).fixedSize() } Divider() + groupRow { + VStack(alignment: .leading, spacing: 1) { + Text(l.monthlyPlanPriceLabel) + Text(l.monthlyPlanPriceHint) + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + TextField("0", value: $monthlyPlanPriceDraft, format: .number.precision(.fractionLength(0...2))) + .multilineTextAlignment(.trailing) + .textFieldStyle(.roundedBorder) + .frame(width: 72) + .focused($monthlyPlanPriceFocused) + .onSubmit { commitMonthlyPlanPrice() } + } + .onAppear { monthlyPlanPriceDraft = store.monthlyPlanPrice } + .onDisappear { commitMonthlyPlanPrice() } + .onChange(of: monthlyPlanPriceFocused) { _, focused in + if !focused { commitMonthlyPlanPrice() } + } + Divider() groupRow { Text(l.limitDisplayModeLabel) Spacer() @@ -920,6 +944,11 @@ struct SettingsView: View { store.setCustomScanRoots(customScanDraft, for: customScanDraftOwnerID) } + private func commitMonthlyPlanPrice() { + store.monthlyPlanPrice = monthlyPlanPriceDraft + monthlyPlanPriceDraft = store.monthlyPlanPrice + } + private func scheduleCustomScanMatchCount() { customScanMatchTask?.cancel() customScanMatchGeneration += 1 diff --git a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift index e7e9d248c..6c096a735 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -68,6 +68,8 @@ final class LocalizationInterpolationTests: XCTestCase { expect(lang, "floatingPetHoverWithLimit", l.floatingPetHoverWithLimit(a, b), a, b) expect(lang, "intervalLabel", l.intervalLabel(1860), "31") // 1860 s → 31 min / 1860초 → 31분 expect(lang, "customScanRootsMatches", l.customScanRootsMatches(4242), "4242") + expect(lang, "subscriptionLeverage", + l.subscriptionLeverage(plan: a, apiEquivalent: b, multiplier: "6.1×"), a, b, "6.1×") // Save transfer / 세이브 이전 expect(lang, "importConfirmBody", diff --git a/Tests/PokeTokenBarTests/PokeTokenBarTests.swift b/Tests/PokeTokenBarTests/PokeTokenBarTests.swift index caf972e39..5f261e3d4 100644 --- a/Tests/PokeTokenBarTests/PokeTokenBarTests.swift +++ b/Tests/PokeTokenBarTests/PokeTokenBarTests.swift @@ -289,6 +289,39 @@ final class ModelDecodingTests: XCTestCase { XCTAssertNil(status.planDisplay) } + func testIsFlatRateSubscription() throws { + var status = try JSONDecoder().decode(LimitStatus.self, from: Data("{}".utf8)) + for plan in ["max", "pro", "team", "MAX", "Pro"] { + status.subscriptionType = plan + XCTAssertTrue(status.isFlatRateSubscription, plan) + } + for plan in ["free", "api", "", nil] as [String?] { + status.subscriptionType = plan + XCTAssertFalse(status.isFlatRateSubscription, plan ?? "nil") + } + } + + func testMultiplierFormat() { + XCTAssertEqual(TokenFormatter.multiplier(6.1), "6.1×") + XCTAssertEqual(TokenFormatter.multiplier(6.0), "6×") + XCTAssertEqual(TokenFormatter.multiplier(12.26), "12.3×") + } + + /// Cost rows keep a bare `$` (#224); the API-equivalent qualifier lives on the leverage row, + /// and the plan price drafts locally like the scan-roots field (#187). + func testLeverageUiShape() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let popover = try String(contentsOf: root.appendingPathComponent("Sources/PokeTokenBar/UI/PopoverView.swift"), encoding: .utf8) + XCTAssertTrue(popover.contains("l.subscriptionLeverage(")) + XCTAssertFalse(popover.contains("(API-equiv.)")) + let settings = try String(contentsOf: root.appendingPathComponent("Sources/PokeTokenBar/UI/SettingsView.swift"), encoding: .utf8) + XCTAssertFalse(settings.contains("$store.monthlyPlanPrice"), + "binding the store directly writes UserDefaults on every keystroke") + XCTAssertTrue(settings.contains("$monthlyPlanPriceDraft")) + XCTAssertTrue(settings.contains(".onDisappear { commitMonthlyPlanPrice() }")) + } + /// 자격증명 파싱이 subscriptionType/rateLimitTier 를 추출하는지 — 실 keychain JSON 형태 기반. func testCredentialParsesPlanFields() throws { let json = Data(""" diff --git a/Tests/PokeTokenBarTests/UsageStoreTests.swift b/Tests/PokeTokenBarTests/UsageStoreTests.swift index 154f09329..e62171c1d 100644 --- a/Tests/PokeTokenBarTests/UsageStoreTests.swift +++ b/Tests/PokeTokenBarTests/UsageStoreTests.swift @@ -1383,4 +1383,161 @@ extension UsageStoreTests { store.menuLimitColorMode = .attention XCTAssertEqual(makeStore(providers: []).menuLimitColorMode, .attention) } + + // MARK: Claude subscription leverage (#200) + + private func claudeMonth(_ cost: Double, coverage: CostCoverage = .source, + reportsCost: Bool = true) -> FakeUsageProvider { + let claude = FakeUsageProvider(id: "claude_code", displayName: "Claude Code", + daily: todayDaily(100_000, cost: 10), reportsCost: reportsCost) + claude.enrichment = monthEnrichment(tokens: 1_000_000, cost: cost, coverage: coverage) + return claude + } + + private func flatRate(_ plan: String? = "max") -> LimitStatus { + var status = claudeLimits(fiveHourUtil: 10) + status.subscriptionType = plan + return status + } + + /// Owner scenario: Claude $610 + another subscription's estimate + a server bill, plan $100. + /// The header keeps summing everything; the leverage numerator is Claude's month only. + func testLeverageUsesOnlyClaudeMonthCost() async { + let gemini = FakeUsageProvider(id: "gemini", displayName: "Gemini", daily: todayDaily(30_000, cost: 8)) + gemini.enrichment = monthEnrichment(tokens: 200_000, cost: 200, coverage: .estimate) + let grok = FakeUsageProvider(id: "grok", displayName: "Grok", daily: todayDaily(20_000, cost: 5)) + grok.enrichment = monthEnrichment(tokens: 50_000, cost: 50, coverage: .source) + let store = makeStore(providers: [claudeMonth(610), gemini, grok], claude: flatRate()) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 100 + + XCTAssertEqual(store.monthCostTotal, 860, accuracy: 0.000_001) + XCTAssertEqual(store.claudeMonthAPIEquivalentCost?.amount ?? -1, 610, accuracy: 0.000_001) + XCTAssertEqual(store.subscriptionLeverage ?? -1, 6.1, accuracy: 0.000_001) + } + + /// Without a Claude snapshot nothing else may stand in for the numerator. + func testOtherProvidersNeverFeedLeverage() async { + let other = FakeUsageProvider(id: "future_tool_xyz", displayName: "Future", daily: todayDaily(1_000, cost: 1)) + other.enrichment = monthEnrichment(tokens: 10_000, cost: 40, coverage: .estimate) + let store = makeStore(providers: [other], claude: flatRate()) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 10 + + XCTAssertNil(store.claudeMonthAPIEquivalentCost) + XCTAssertNil(store.subscriptionLeverage) + } + + /// Provenance comes from the records: a month mixing source-reported, estimated and unpriced + /// Claude records counts the known amount and stays marked partial for the tooltip. + func testLeverageKeepsRecordLevelCoverage() async { + var mixed = CostCoverage.source + mixed.merge(.estimate) + mixed.merge(.unavailable) + let store = makeStore(providers: [claudeMonth(300, coverage: mixed)], claude: flatRate()) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 100 + + XCTAssertEqual(store.claudeMonthAPIEquivalentCost?.coverage, mixed) + XCTAssertEqual(store.subscriptionLeverage ?? -1, 3, accuracy: 0.000_001) + let l = L(.en) + XCTAssertEqual(store.claudeMonthAPIEquivalentCost?.explanation(l), l.costPartialHint) + } + + func testLeverageGates() async { + let maxStore = makeStore(providers: [claudeMonth(610)], claude: flatRate()) + await maxStore.refresh(scheduleEmptyRetry: false) + XCTAssertNil(maxStore.subscriptionLeverage, "price unset") + maxStore.monthlyPlanPrice = 100 + XCTAssertNotNil(maxStore.subscriptionLeverage) + maxStore.monthlyPlanPrice = 0 + XCTAssertNil(maxStore.subscriptionLeverage, "price 0 turns the row off") + + for plan in ["free", nil] as [String?] { + let store = makeStore(providers: [claudeMonth(610)], claude: flatRate(plan)) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 100 + XCTAssertNil(store.subscriptionLeverage, "\(plan ?? "nil") plan") + } + + let noLimits = makeStore(providers: [claudeMonth(610)], claude: nil) + await noLimits.refresh(scheduleEmptyRetry: false) + noLimits.monthlyPlanPrice = 100 + XCTAssertNil(noLimits.subscriptionLeverage, "no credential plan") + + let unpriced = makeStore(providers: [claudeMonth(0, coverage: .unavailable)], claude: flatRate()) + await unpriced.refresh(scheduleEmptyRetry: false) + unpriced.monthlyPlanPrice = 100 + XCTAssertNil(unpriced.subscriptionLeverage, "no known Claude $ → no 0× row") + + let tokenOnly = makeStore(providers: [claudeMonth(610, reportsCost: false)], claude: flatRate()) + await tokenOnly.refresh(scheduleEmptyRetry: false) + tokenOnly.monthlyPlanPrice = 100 + XCTAssertNil(tokenOnly.subscriptionLeverage, "a non-costing snapshot never feeds the row") + } + + func testLeverageOpensForProAndTeam() async { + for plan in ["pro", "team", "PRO"] { + let store = makeStore(providers: [claudeMonth(200)], claude: flatRate(plan)) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 20 + XCTAssertEqual(store.subscriptionLeverage ?? -1, 10, accuracy: 0.000_001, plan) + } + } + + /// The month is machine-wide while the plan is one login's, so several Claude logins hide + /// the row rather than divide mixed usage by one plan. + func testLeverageHiddenWithSeveralClaudeAccounts() async throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ptb-leverage-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: folder) } + testDefaults.set(folder.path, forKey: ClaudeAccountRoots.defaultsKey) + let maxPlan = flatRate() + let store = UsageStore(providers: [claudeMonth(610)], + claudeLimitsProvider: FakeClaudeLimits(status: maxPlan), + additionalClaudeLimitsProvider: { _ in FakeClaudeLimits(status: maxPlan) }, + codexLimitsProvider: FakeCodexLimits(status: nil), + antigravityLimitsProvider: FakeAntigravityLimits(status: nil), + autoRefresh: false, + defaults: testDefaults) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 100 + + XCTAssertEqual(store.claudeAccounts.count, 2) + XCTAssertNil(store.subscriptionLeverage) + } + + func testMonthlyPlanPricePersistsAndSanitizes() { + let store = makeStore(providers: []) + XCTAssertEqual(store.monthlyPlanPrice, 0) + store.monthlyPlanPrice = 100 + XCTAssertEqual(testDefaults.double(forKey: "monthlyPlanPrice"), 100) + XCTAssertEqual(makeStore(providers: []).monthlyPlanPrice, 100) + store.monthlyPlanPrice = -5 + XCTAssertEqual(store.monthlyPlanPrice, 0) + store.monthlyPlanPrice = .nan + XCTAssertEqual(store.monthlyPlanPrice, 0) + testDefaults.set(-3.0, forKey: "monthlyPlanPrice") + XCTAssertEqual(makeStore(providers: []).monthlyPlanPrice, 0, "a bad stored value loads as off") + } + + func testLeverageStaysOutOfTheMenuBar() async { + let store = makeStore(providers: [claudeMonth(610)], claude: flatRate()) + store.showTokensInMenu = true + store.showCostInMenu = true + store.showLimitInMenu = false + store.monthlyPlanPrice = 100 + await store.refresh(scheduleEmptyRetry: false) + XCTAssertNotNil(store.subscriptionLeverage) + let joined = store.menuLines.joined(separator: " ") + XCTAssertFalse(joined.contains("API")) + XCTAssertFalse(joined.contains("×")) + } +} + +private func monthEnrichment(tokens: Int, cost: Double, coverage: CostCoverage) -> ProviderEnrichment { + var e = ProviderEnrichment() + e.monthTotal = PeriodUsage(period: "month", totalTokens: tokens, totalCost: cost, costCoverage: coverage) + e.periodsOK = true + return e } From f0420a330e9a11a2c41f0da888e0678492684608 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 3 Oct 2026 22:53:43 +0300 Subject: [PATCH 2/2] docs: record Claude-scoped leverage and record-level provenance; cover infinite plan price --- Tests/PokeTokenBarTests/UsageStoreTests.swift | 2 ++ docs/reference/defect-log.md | 7 +++++++ docs/reference/provider-extension.md | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/Tests/PokeTokenBarTests/UsageStoreTests.swift b/Tests/PokeTokenBarTests/UsageStoreTests.swift index e62171c1d..ceed4f006 100644 --- a/Tests/PokeTokenBarTests/UsageStoreTests.swift +++ b/Tests/PokeTokenBarTests/UsageStoreTests.swift @@ -1517,6 +1517,8 @@ extension UsageStoreTests { XCTAssertEqual(store.monthlyPlanPrice, 0) store.monthlyPlanPrice = .nan XCTAssertEqual(store.monthlyPlanPrice, 0) + store.monthlyPlanPrice = .infinity + XCTAssertEqual(store.monthlyPlanPrice, 0, "an infinite price would show a 0× row") testDefaults.set(-3.0, forKey: "monthlyPlanPrice") XCTAssertEqual(makeStore(providers: []).monthlyPlanPrice, 0, "a bad stored value loads as off") } diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 2cba9ec05..9cf032b27 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -633,6 +633,13 @@ read_when: ## 표시·UI +- **구독 플래그를 모든 `$` 에 덮어쓰지 말고, 출처는 레코드 단위로 판정하라.** Claude Max 플래그로 + 모든 행에 "API 환산"을 찍으면 Grok 의 실제 청구서까지 환산이 된다(#224). 프로바이더 단위 + `costIsEstimate` 도 같은 실수의 축소판이다 — 한 프로바이더 안에서도 레코드마다 source/estimate/ + unavailable 이 섞인다. 레버리지 분자는 Claude 월 `usageCost`(레코드별 `CostCoverage` 병합)만 + 쓰고, Claude 로그인이 여럿이면 월 합계가 기기 전체라 행을 숨긴다. 회귀: + `testLeverageUsesOnlyClaudeMonthCost`, `testOtherProvidersNeverFeedLeverage`, + `testLeverageHiddenWithSeveralClaudeAccounts`. (#200, #249) - **Antigravity 그룹 표시명은 한 헬퍼로.** API 의 `displayName`("Gemini Models" 등)을 알림·사탕· 펫 버블에 그대로 넣으면 앱 언어가 한국어여도 본문에 영어가 섞인다. 팝오버만 `L` 로 바꾸던 분기를 `L.antigravityGroupTitle` 로 끌어올려 candy / `buildLimitWindows` / 팝오버가 공유한다. diff --git a/docs/reference/provider-extension.md b/docs/reference/provider-extension.md index 3dc5aa191..1cd261090 100644 --- a/docs/reference/provider-extension.md +++ b/docs/reference/provider-extension.md @@ -20,6 +20,10 @@ read_when: - **프로바이더 고유 동작만 `providerID` 로 명시 분기**: 공식 한도(Claude=HTTP·Codex=프로세스), 5h forecast·"현재 블록" 행처럼 *특정 프로바이더에만 존재하는* 기능만 id 로 조건 분기한다. 범용 경로에 `== "claude_code"` 류 리터럴 분기를 추가하는 건 금지. + 구독 레버리지 행(#200)도 이 부류다 — 게이트가 Claude 플랜·Claude 구독료라 분자는 Claude 스냅샷의 + 월 `usageCost` 만 id 로 조회한다(`claudeMonthAPIEquivalentCost`). `$` 의 출처(source/estimate/ + unavailable)는 프로바이더 단위 플래그가 아니라 레코드별 `CostCoverage` 로 판정한다. 다른 + 프로바이더의 레버리지는 그 프로바이더의 플랜·가격 입력과 함께 따로 추가한다. - **버전매니저/설치경로 추가** = `BinaryLocator.commonToolDirectories()` 한 곳에만 추가한다 (탐색·자식 프로세스 PATH 보강이 이 단일 소스를 공유). - **로그 스캔 루트 추가** = `LocalUsageReader.claudeProjectRoots` 같은 프로바이더별 루트 목록 한 곳에만