diff --git a/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift b/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift index 5def82de..5c1a3c7d 100644 --- a/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift +++ b/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift @@ -13,6 +13,9 @@ private enum LocalAdditionalSource: String, Sendable { struct LocalOpenCodeProvider: UsageProvider { let id = "opencode" let displayName = "OpenCode" + /// OpenCode stores a per-message `cost` from the provider. That is a bill, + /// not our ModelPricing table (#224 / #200). + let costIsEstimate = false func fetchDaily() async throws -> DailyUsage? { let entries = await LocalAdditionalUsageCache.shared.entries(for: .opencode) @@ -29,6 +32,9 @@ struct LocalOpenCodeProvider: UsageProvider { struct LocalHermesProvider: UsageProvider { let id = "hermes" let displayName = "Hermes Agent" + /// Prefers `actual_cost_usd`, else Hermes's own `estimated_cost_usd` — both + /// are the agent's figure, not ModelPricing (#224 / #200). + let costIsEstimate = false func fetchDaily() async throws -> DailyUsage? { let entries = await LocalAdditionalUsageCache.shared.entries(for: .hermes) diff --git a/Sources/PokeTokenBar/Core/LocalUsageProvider.swift b/Sources/PokeTokenBar/Core/LocalUsageProvider.swift index de08c5df..1cae2c75 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageProvider.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageProvider.swift @@ -106,6 +106,9 @@ struct LocalAntigravityProvider: UsageProvider { struct LocalGrokProvider: UsageProvider { let id = "grok" let displayName = "Grok" + /// Session logs persist xAI's charge (`explicitCost`). That `$` is a bill, + /// not ModelPricing — do not fold it into subscription leverage (#224). + let costIsEstimate = false func fetchDaily() async throws -> DailyUsage? { let now = Date() diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index e763ad04..b78f25a1 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -124,6 +124,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("월 구독료 ($)", "Monthly plan price ($)", "月額プラン ($)", "Precio del plan mensual ($)", "Prix du forfait mensuel ($)", "Preço do plano mensal ($)", "Monatlicher Planpreis ($)") + } + var monthlyPlanPriceHint: String { + t("선택. Claude Max/Pro/Team 레버리지 행에만 씁니다. 0 이면 행을 숨깁니다.", + "Optional. Claude Max/Pro/Team leverage row only. Set to 0 to hide it.", + "任意。Claude Max/Pro/Team のレバレッジ行にだけ使います。0 なら行を隠します。", + "Opcional. Solo para la fila Max/Pro/Team de Claude. 0 la oculta.", + "Facultatif. Uniquement la ligne Claude Max/Pro/Team. 0 = ligne masquée.", + "Opcional. Só na linha Claude Max/Pro/Team. 0 esconde a linha.", + "Optional. Nur für die Claude Max/Pro/Team-Hebelzeile. 0 blendet sie aus.") + } + /// #200 payoff line. `plan` and `apiEquivalent` already include `$`; `multiplier` already includes `×`. + func subscriptionLeverage(plan: String, apiEquivalent: String, multiplier: String) -> String { + t("플랜 \(plan)/월 · API 환산 \(apiEquivalent) · \(multiplier)", + "plan \(plan)/mo · API-equiv \(apiEquivalent) · \(multiplier)", + "プラン \(plan)/月 · API換算 \(apiEquivalent) · \(multiplier)", + "plan \(plan)/mes · equiv. API \(apiEquivalent) · \(multiplier)", + "forfait \(plan)/mois · éq. API \(apiEquivalent) · \(multiplier)", + "plano \(plan)/mês · equiv. API \(apiEquivalent) · \(multiplier)", + "Plan \(plan)/Monat · API-äquiv. \(apiEquivalent) · \(multiplier)") + } var limitPercent: String { t("한도 %", "Limit %", "上限 %", "Límite %", "Limite %", "Limite %", "Limit %") } var animationQualityLabel: String { t("애니메이션", "Animation", "アニメーション", "Animación", "Animation", "Animação", "Animation") } var animationQualityHint: String { diff --git a/Sources/PokeTokenBar/Core/Models.swift b/Sources/PokeTokenBar/Core/Models.swift index dce241b6..ddad8908 100644 --- a/Sources/PokeTokenBar/Core/Models.swift +++ b/Sources/PokeTokenBar/Core/Models.swift @@ -238,6 +238,18 @@ struct LimitStatus: Decodable, Sendable { return "\(accountEmail) · \(org)" } + /// Max/Pro/Team are billed as a flat monthly plan, so ModelPricing `$` is + /// API-equivalent — the gate for the leverage row (#200), not a caption to + /// stamp on every provider. Free / missing plan stay billed-like-API. + /// Credential JSON may mix case. Kept on this type so generic totals never + /// branch on `providerID` (provider-extension rule). + 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? { @@ -494,6 +506,8 @@ struct ProviderSnapshot: Sendable, Identifiable { var fetchedAt: Date /// Mirrors `UsageProvider.reportsCost`. Default keeps existing call sites unchanged. var reportsCost: Bool = true + /// Mirrors `UsageProvider.costIsEstimate`. Default is table-priced (estimate). + var costIsEstimate: Bool = true var id: String { providerID } var todayTotalTokens: Int { today?.totalTokens ?? 0 } diff --git a/Sources/PokeTokenBar/Core/TokenFormatter.swift b/Sources/PokeTokenBar/Core/TokenFormatter.swift index a0115fa6..5fe97f46 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/UsageProvider.swift b/Sources/PokeTokenBar/Core/UsageProvider.swift index 822bde78..8efc3f1e 100644 --- a/Sources/PokeTokenBar/Core/UsageProvider.swift +++ b/Sources/PokeTokenBar/Core/UsageProvider.swift @@ -8,6 +8,13 @@ protocol UsageProvider: Sendable { /// Flat-rate subscriptions (e.g. Cursor) report tokens only. var reportsCost: Bool { get } + /// Whether a reported `$` is ModelPricing × tokens (an estimate) rather than + /// a server-persisted charge. Only meaningful when `reportsCost` is true. + /// Default is estimate so a new table-priced source does not inherit a bill + /// flag — Grok/OpenCode/Hermes opt out explicitly. Do not infer this from + /// Claude's subscription plan (#224: a global Max flag labeled Grok). + var costIsEstimate: Bool { get } + /// 오늘 합계 (critical path) — 메뉴바 숫자와 stale 판정의 기준. /// 데이터 소스 자체가 없거나 오늘 사용량이 없으면 nil. func fetchDaily() async throws -> DailyUsage? @@ -18,6 +25,7 @@ protocol UsageProvider: Sendable { extension UsageProvider { var reportsCost: Bool { true } + var costIsEstimate: Bool { true } } /// 부가 정보 수집 결과. *OK 플래그가 false 면 수집 실패 → 이전 값 유지. diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 4426f933..71799608 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -140,6 +140,19 @@ final class UsageStore { } } + /// Optional user-entered monthly subscription price (USD). 0 / empty = off. + /// No hardcoded plan table — prices change by seat and region (#200). + var monthlyPlanPrice: Double { + didSet { + let clamped = max(0, monthlyPlanPrice) + if clamped != monthlyPlanPrice { + monthlyPlanPrice = clamped + return + } + defaults.set(monthlyPlanPrice, forKey: "monthlyPlanPrice") + } + } + static let intervalPresets: [(label: String, value: TimeInterval)] = [ ("수동", 0), ("1분", 60), ("2분", 120), ("5분", 300), ("15분", 900), ] @@ -299,6 +312,28 @@ final class UsageStore { var monthTotalTokens: Int { snapshots.reduce(0) { $0 + ($1.monthTotal?.totalTokens ?? 0) } } var monthCostTotal: Double { costingSnapshots.reduce(0) { $0 + ($1.monthTotal?.totalCost ?? 0) } } + /// Claude calendar-month ModelPricing `$`. The leverage gate reads the + /// Claude plan, so the numerator is Claude only — Gemini/Codex estimates + /// ride other subscriptions and would inflate Max/Pro/Team × (#249). + /// Grok/OpenCode/Hermes bills stay in `monthCostTotal` (#224). This is + /// not a generic cost total; `monthCostTotal` still sums every snapshot. + var monthAPIEquivalentCost: Double { + costingSnapshots + .filter { $0.costIsEstimate && $0.providerID == "claude_code" } + .reduce(0) { $0 + ($1.monthTotal?.totalCost ?? 0) } + } + + /// Max/Pro/Team + a user-entered plan price + a non-zero estimate `$Y`. + /// Ratio is `$Y / plan`, matching #200's "6.1×" example. + var subscriptionLeverage: Double? { + guard limits?.isFlatRateSubscription == true, + monthlyPlanPrice > 0, + monthAPIEquivalentCost > 0 else { return nil } + return monthAPIEquivalentCost / monthlyPlanPrice + } + + var showsLeverage: Bool { subscriptionLeverage != nil } + /// Claude 의 활성 5h 블록 — 5h forecast·"현재 블록" 행은 Claude 공식 한도와 짝이므로 /// providerID 로 명시 조회한다 (전 프로바이더가 블록을 갖게 된 후 first-with-block 은 오매칭). private var claudeActiveBlock: BlockUsage? { @@ -525,6 +560,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 = d.object(forKey: "monthlyPlanPrice") as? Double ?? 0 if let credential = sessionKeys.credential() { sessionKeyConfigured = true @@ -683,7 +719,8 @@ final class UsageStore { weekTotal: prevWeek, monthTotal: prevMonth, fetchedAt: Date(), - reportsCost: provider.reportsCost)) + reportsCost: provider.reportsCost, + costIsEstimate: provider.costIsEstimate)) } } snapshots = newSnapshots @@ -719,7 +756,8 @@ final class UsageStore { weekTotal: enrichment.periodsOK ? enrichment.weekTotal : nil, monthTotal: enrichment.periodsOK ? enrichment.monthTotal : nil, fetchedAt: Date(), - reportsCost: provider.reportsCost)) + reportsCost: provider.reportsCost, + costIsEstimate: provider.costIsEstimate)) } continue } diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 42a54346..1f322157 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -170,6 +170,15 @@ struct PopoverView: View { } .padding(.top, 2) } + if store.showsLeverage, let ratio = store.subscriptionLeverage { + Text(l.subscriptionLeverage( + plan: TokenFormatter.cost(store.monthlyPlanPrice), + apiEquivalent: TokenFormatter.cost(store.monthAPIEquivalentCost), + multiplier: TokenFormatter.multiplier(ratio))) + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.top, 2) + } // 연결된 서비스가 2개 이상이면 작은 탭으로 서비스별 상세를 넘나든다 // (합계는 위에 유지 — 상세·한도만 탭 스코프). diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 7eef12d3..19b34a31 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -33,6 +33,8 @@ struct SettingsView: View { @State private var customScanMatchTask: Task? @State private var customScanMatchGeneration = 0 @FocusState private var customScanFocused: Bool + @State private var monthlyPlanPriceDraft: Double = 0 + @FocusState private var monthlyPlanPriceFocused: Bool private var l: L { companion.l } private var isBundledApp: Bool { AppEnv.isBundledApp } @@ -81,10 +83,12 @@ struct SettingsView: View { } .frame(height: 460) .onAppear { + monthlyPlanPriceDraft = store.monthlyPlanPrice guard !didApplyStartExpanded else { return } didApplyStartExpanded = true if startExpanded { advancedExpanded = true } } + .onDisappear { commitMonthlyPlanPrice() } } private var header: some View { @@ -190,6 +194,26 @@ 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() } + .onChange(of: monthlyPlanPriceFocused) { _, focused in + if !focused { commitMonthlyPlanPrice() } + } + } + Divider() groupRow { Text(l.limitDisplayModeLabel) Spacer() @@ -651,6 +675,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 5046f7ab..41213859 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -59,6 +59,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 66af0013..8d285ef1 100644 --- a/Tests/PokeTokenBarTests/PokeTokenBarTests.swift +++ b/Tests/PokeTokenBarTests/PokeTokenBarTests.swift @@ -289,6 +289,75 @@ final class ModelDecodingTests: XCTestCase { XCTAssertNil(status.planDisplay) } + /// Max/Pro/Team are flat-rate — the gate for the leverage row, not a caption + /// stamped on every `$`. Free / missing plan stay billed-like-API. + /// A=false (free/nil) alone would leave the subscription branch untested. + 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) is a flat-rate subscription") + } + for plan in ["free", "api", "", nil] as [String?] { + status.subscriptionType = plan + XCTAssertFalse(status.isFlatRateSubscription, + "\(plan ?? "nil") is billed like API (or unknown)") + } + } + + func testMultiplierFormat() { + XCTAssertEqual(TokenFormatter.multiplier(6.1), "6.1×") + XCTAssertEqual(TokenFormatter.multiplier(6.0), "6×") + XCTAssertEqual(TokenFormatter.multiplier(12.26), "12.3×") + } + + /// #224 closed because a global Claude-plan flag labeled Grok. The popover + /// must not stamp `(API-equiv.)` on every cost row; the qualifier lives on + /// the leverage line. Menu-bar costCompact stays a bare `$`. + func testPopoverLeverageDoesNotRelabelEveryCostRow() 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("showsLeverage"), + "popover must render the leverage row, not a global caption") + XCTAssertTrue(popover.contains("subscriptionLeverage"), + "leverage copy goes through L so 7 languages stay in lockstep") + XCTAssertFalse(popover.contains("apiEquivalentCaption"), + "per-row (API-equiv.) caption is the #224 shape the owner rejected") + XCTAssertFalse(popover.contains("(API-equiv.)"), + "inline English caption on a cost row is the same #224 lie") + let settings = try String(contentsOf: root.appendingPathComponent("Sources/PokeTokenBar/UI/SettingsView.swift"), encoding: .utf8) + XCTAssertTrue(settings.contains("monthlyPlanPrice"), + "Settings must expose the optional plan price (#200 item 2)") + XCTAssertFalse(settings.contains("$store.monthlyPlanPrice"), + "direct TextField binding to the store writes UserDefaults on every keystroke (#187)") + XCTAssertTrue(settings.contains("monthlyPlanPriceDraft"), + "plan price must draft locally and commit on submit/blur") + XCTAssertTrue(settings.contains("commitMonthlyPlanPrice"), + "submit, blur, and leaving Settings must share one commit") + XCTAssertTrue(settings.contains("onDisappear"), + "Back without blur must still persist the typed price") + let formatter = try String(contentsOf: root.appendingPathComponent("Sources/PokeTokenBar/Core/TokenFormatter.swift"), encoding: .utf8) + XCTAssertFalse(formatter.contains("labeled"), + "cost() must stay unlabeled — captions belong on the leverage row") + } + + /// Table-priced sources are estimates; sources that persist a server charge are bills. + /// Default (protocol) is estimate so a new provider does not inherit Grok's bill flag. + func testCostIsEstimateIsPerProviderNotAClaudeFlag() { + XCTAssertTrue(LocalClaudeProvider().costIsEstimate) + XCTAssertTrue(LocalGeminiProvider().costIsEstimate) + XCTAssertTrue(LocalCodexProvider().costIsEstimate) + XCTAssertFalse(LocalGrokProvider().costIsEstimate) + XCTAssertFalse(LocalOpenCodeProvider().costIsEstimate) + XCTAssertFalse(LocalHermesProvider().costIsEstimate) + XCTAssertTrue(LocalCursorProvider().costIsEstimate, + "unused when reportsCost is false; default stays estimate") + XCTAssertFalse(LocalCursorProvider().reportsCost) + } + /// 자격증명 파싱이 subscriptionType/rateLimitTier 를 추출하는지 — 실 keychain JSON 형태 기반. func testCredentialParsesPlanFields() throws { let json = Data(""" diff --git a/Tests/PokeTokenBarTests/UsageStoreTests.swift b/Tests/PokeTokenBarTests/UsageStoreTests.swift index 3e69973e..e2c0ae6b 100644 --- a/Tests/PokeTokenBarTests/UsageStoreTests.swift +++ b/Tests/PokeTokenBarTests/UsageStoreTests.swift @@ -13,14 +13,17 @@ private final class FakeUsageProvider: UsageProvider, @unchecked Sendable { let id: String let displayName: String let reportsCost: Bool + let costIsEstimate: Bool nonisolated(unsafe) var daily: DailyUsage? nonisolated(unsafe) var enrichment = ProviderEnrichment() nonisolated(unsafe) var failDaily = false - init(id: String, displayName: String, daily: DailyUsage? = nil, reportsCost: Bool = true) { + init(id: String, displayName: String, daily: DailyUsage? = nil, + reportsCost: Bool = true, costIsEstimate: Bool = true) { self.id = id self.displayName = displayName self.reportsCost = reportsCost + self.costIsEstimate = costIsEstimate self.daily = daily } func fetchDaily() async throws -> DailyUsage? { @@ -880,6 +883,26 @@ final class UsageStoreTests: XCTestCase { "탭에 노출 안 됨") } + /// Leverage `$Y` divides by the Claude plan price, so a future estimate + /// source must not inflate the Max/Pro/Team multiple (#249 review). + func testUnknownEstimateProviderDoesNotInflateClaudeLeverage() async { + let future = FakeUsageProvider( + id: "future_tool_xyz", displayName: "Future Tool", + daily: todayDaily(1_000, cost: 1), costIsEstimate: true) + future.enrichment = monthEnrichment(tokens: 10_000, cost: 40) + var maxPlan = claudeLimits(fiveHourUtil: 10) + maxPlan.subscriptionType = "max" + let store = makeStore(providers: [future], claude: maxPlan) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 10 + XCTAssertEqual(store.monthCostTotal, 40, accuracy: 0.000_001, + "header month $ still sums every costing snapshot") + XCTAssertEqual(store.monthAPIEquivalentCost, 0, accuracy: 0.000_001, + "leverage $Y is the Claude plan numerator, not every estimate") + XCTAssertNil(store.subscriptionLeverage, + "no Claude month $ → no 4× from an unrelated tool") + } + /// 기본 등록 프로바이더 레지스트리 무결성 — 비어 있지 않고 id 가 유일. /// (새 프로바이더를 배열에 등록하는 단일 지점이 살아있는지 최소 보증.) func testDefaultProviderRegistryHasUniqueIds() { @@ -1279,4 +1302,142 @@ final class UsageStoreTests: XCTestCase { XCTAssertEqual(store.menuLines, [TokenFormatter.compact(50_000)], "Cursor-only must not render $0.00 / $0.0 in the menu bar") } + + // MARK: subscription leverage (#200 / #224 close) + + /// Owner #224: a Claude Max plan must not treat Grok's server charge as API-equivalent. + /// #249: Gemini (and Codex) estimates ride other subscriptions — they must not + /// inflate the Max multiple. Combined month `$` still sums every costing snapshot. + func testMonthAPIEquivalentCostExcludesBilledProviders() async { + let claude = FakeUsageProvider( + id: "claude_code", displayName: "Claude Code", + daily: todayDaily(100_000, cost: 10), costIsEstimate: true) + claude.enrichment = monthEnrichment(tokens: 1_000_000, cost: 610) + let grok = FakeUsageProvider( + id: "grok", displayName: "Grok", + daily: todayDaily(20_000, cost: 5), costIsEstimate: false) + grok.enrichment = monthEnrichment(tokens: 50_000, cost: 50) + let billedUnknown = FakeUsageProvider( + id: "future_bill_xyz", displayName: "Future Bill", + daily: todayDaily(1_000, cost: 1), costIsEstimate: false) + billedUnknown.enrichment = monthEnrichment(tokens: 10_000, cost: 40) + let gemini = FakeUsageProvider( + id: "gemini", displayName: "Gemini", + daily: todayDaily(30_000, cost: 8), costIsEstimate: true) + gemini.enrichment = monthEnrichment(tokens: 200_000, cost: 200) + var maxPlan = claudeLimits(fiveHourUtil: 10) + maxPlan.subscriptionType = "max" + let store = makeStore(providers: [claude, grok, billedUnknown, gemini], claude: maxPlan) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 100 + + XCTAssertEqual(store.monthCostTotal, 900, accuracy: 0.000_001, + "header month $ still includes bills and other estimates") + XCTAssertEqual(store.monthAPIEquivalentCost, 610, accuracy: 0.000_001, + "leverage $Y is Claude's estimate — Gemini/Grok must not mix into the Max multiple") + XCTAssertEqual(store.subscriptionLeverage ?? -1, 6.1, accuracy: 0.000_001, + "owner example is 610/100 = 6.1×, not 810/100 or 900/100") + XCTAssertEqual(store.snapshot(preferring: "grok")?.costIsEstimate, false) + XCTAssertEqual(store.snapshot(preferring: "future_bill_xyz")?.costIsEstimate, false) + XCTAssertEqual(store.snapshot(preferring: "claude_code")?.costIsEstimate, true) + XCTAssertEqual(store.snapshot(preferring: "gemini")?.costIsEstimate, true) + } + + /// Leverage is the #200 payoff: plan $100 /mo against $610 API-equiv → 6.1×. + /// A=false branches (no plan / Free / price 0 / $Y 0) must each fail closed. + func testSubscriptionLeverageGates() async { + let claude = FakeUsageProvider( + id: "claude_code", displayName: "Claude Code", + daily: todayDaily(100_000, cost: 10), costIsEstimate: true) + claude.enrichment = monthEnrichment(tokens: 1_000_000, cost: 610) + + var maxPlan = claudeLimits(fiveHourUtil: 10) + maxPlan.subscriptionType = "max" + let maxStore = makeStore(providers: [claude], claude: maxPlan) + await maxStore.refresh(scheduleEmptyRetry: false) + XCTAssertNil(maxStore.subscriptionLeverage, "price unset → no row") + maxStore.monthlyPlanPrice = 100 + XCTAssertEqual(maxStore.subscriptionLeverage ?? -1, 6.1, accuracy: 0.000_001) + XCTAssertTrue(maxStore.showsLeverage) + + maxStore.monthlyPlanPrice = 0 + XCTAssertNil(maxStore.subscriptionLeverage, "price 0 is Settings-off") + + var freePlan = claudeLimits(fiveHourUtil: 10) + freePlan.subscriptionType = "free" + let freeStore = makeStore(providers: [claude], claude: freePlan) + await freeStore.refresh(scheduleEmptyRetry: false) + freeStore.monthlyPlanPrice = 100 + XCTAssertNil(freeStore.subscriptionLeverage, "Free is billed like API — no leverage") + + let apiKeyStore = makeStore(providers: [claude], claude: nil) + await apiKeyStore.refresh(scheduleEmptyRetry: false) + apiKeyStore.monthlyPlanPrice = 100 + XCTAssertNil(apiKeyStore.subscriptionLeverage, "no credential plan → no leverage") + + let empty = FakeUsageProvider( + id: "claude_code", displayName: "Claude Code", + daily: todayDaily(1, cost: 0), costIsEstimate: true) + empty.enrichment = monthEnrichment(tokens: 1, cost: 0) + let zeroY = makeStore(providers: [empty], claude: maxPlan) + await zeroY.refresh(scheduleEmptyRetry: false) + zeroY.monthlyPlanPrice = 100 + XCTAssertNil(zeroY.subscriptionLeverage, "Y=0 → no 0× row") + } + + /// Team/Pro (B=true without Max) must also open the row — Max-only tests hide that branch. + func testLeverageOpensForProAndTeam() async { + let claude = FakeUsageProvider( + id: "claude_code", displayName: "Claude Code", + daily: todayDaily(10_000, cost: 1), costIsEstimate: true) + claude.enrichment = monthEnrichment(tokens: 100_000, cost: 200) + for plan in ["pro", "team", "PRO"] { + var status = claudeLimits(fiveHourUtil: 10) + status.subscriptionType = plan + let store = makeStore(providers: [claude], claude: status) + await store.refresh(scheduleEmptyRetry: false) + store.monthlyPlanPrice = 20 + XCTAssertEqual(store.subscriptionLeverage ?? -1, 10, accuracy: 0.000_001, "\(plan)") + } + } + + func testMonthlyPlanPricePersistsInDefaults() { + let claude = FakeUsageProvider(id: "claude_code", displayName: "Claude Code", daily: todayDaily(1_000)) + let store = makeStore(providers: [claude]) + XCTAssertEqual(store.monthlyPlanPrice, 0) + store.monthlyPlanPrice = 100 + XCTAssertEqual(testDefaults.double(forKey: "monthlyPlanPrice"), 100) + let again = makeStore(providers: [claude]) + XCTAssertEqual(again.monthlyPlanPrice, 100) + store.monthlyPlanPrice = -5 + XCTAssertEqual(store.monthlyPlanPrice, 0, "negative plan price clamps to off") + } + + /// Menu bar stays unlabeled — #200 design question, confirmed by #224 close + /// ("bare number is the convention") and by not paying caption width on the 1-line surface. + func testLeverageDoesNotAppearInMenuBar() async { + let claude = FakeUsageProvider( + id: "claude_code", displayName: "Claude Code", + daily: todayDaily(100_000, cost: 10), costIsEstimate: true) + claude.enrichment = monthEnrichment(tokens: 1_000_000, cost: 610) + var maxPlan = claudeLimits(fiveHourUtil: 10) + maxPlan.subscriptionType = "max" + let store = makeStore(providers: [claude], claude: maxPlan) + store.showTokensInMenu = true + store.showCostInMenu = true + store.showLimitInMenu = false + store.monthlyPlanPrice = 100 + await store.refresh(scheduleEmptyRetry: false) + let joined = store.menuLines.joined(separator: " ") + XCTAssertFalse(joined.contains("API"), "menu bar must not grow an API-equiv caption") + XCTAssertFalse(joined.contains("×"), "leverage lives in the popover, not the menu") + XCTAssertTrue(joined.contains(TokenFormatter.costCompact(10))) + } +} + +private func monthEnrichment(tokens: Int, cost: Double) -> ProviderEnrichment { + var e = ProviderEnrichment() + e.monthTotal = PeriodUsage(period: "month", totalTokens: tokens, totalCost: cost) + e.periodsOK = true + return e } diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index da846ae6..df76eecd 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -377,6 +377,14 @@ read_when: 초록인데 #174 가 다시 산다. (#174) ## 표시·UI +- **구독 플래그를 모든 프로바이더 `$` 에 덮어쓰지 마라.** Claude Max/Pro/Team 은 ModelPricing + 달러가 청구서가 아니지만, Grok/OpenCode/Hermes 는 서버가 남긴 실제 요금이다. 전역 + `labelsCostAsAPIEquivalent` 를 합계 헤더와 모든 행에 찍으면 Grok 행이 "API 환산"이 되어 + 사실이 뒤집힌다(#224). 판정은 프로바이더 필드 `costIsEstimate`(기본 true, 청구 소스는 + 명시적 false). 레버리지 `$Y` 는 Claude 월 estimate 만 합산하고(게이트가 Claude + 플랜·구독료라 Gemini/Codex 를 섞으면 배수가 부푼다, #249), 캡션은 레버리지 행에만 둔다. + 회귀: `testMonthAPIEquivalentCostExcludesBilledProviders`, + `testPopoverLeverageDoesNotRelabelEveryCostRow`. (#200) - **앱 언어와 시스템 로케일은 다른 축이다 — SwiftUI 가 스스로 만드는 문장은 로케일을 따른다.** `L` 문구는 `AppLanguage` 를 따르는데 `Text(_, style: .relative)` 는 `Locale.current` 를 따라, 한국어 Mac 에서 앱을 영어로 쓰면 "Catch log" 옆에 "3시간 46분" 이 붙는 한 화면 두 언어가 된다. 팝오버 루트 diff --git a/docs/reference/provider-extension.md b/docs/reference/provider-extension.md index 9cf3f478..dceb7136 100644 --- a/docs/reference/provider-extension.md +++ b/docs/reference/provider-extension.md @@ -17,6 +17,14 @@ read_when: 합산이어야 한다(`snapshots` reduce). 한 프로바이더에만 계산을 붙이지 마라(과거 회귀: burn 이 Claude 블록만 관측 → Codex/Gemini 전용 사용자 companion 이 항상 idle). 패리티 테스트가 이를 강제한다 (`UsageStoreTests` 의 "unknown provider" 계열). +- **`$` 가 견적인지 청구인지는 프로바이더가 말한다.** `reportsCost` 는 달러를 그릴지, + `costIsEstimate` 는 그 달러가 ModelPricing 환산인지 서버 요금인지. 기본은 estimate + (표 가격 소스). Grok/OpenCode/Hermes 처럼 로그에 요금이 있으면 `costIsEstimate = false`. + Claude Max 플랜을 전역 플래그로 모든 행에 씌우지 마라(#224). 범용 합계 경로에 + `== "claude_code"` 로 이 판정을 넣지 않는다. 레버리지 `$Y` 는 Claude 월 견적만 + 합산한다 — 게이트가 Claude Max/Pro/Team 이고 사용자가 넣는 건 Claude 구독료라서 + Gemini/Codex 견적을 섞으면 배수가 부풀어 오른다(#249). `monthCostTotal` 헤더는 + 그대로 전 프로바이더 합산. - **프로바이더 고유 동작만 `providerID` 로 명시 분기**: 공식 한도(Claude=HTTP·Codex=프로세스), 5h forecast·"현재 블록" 행처럼 *특정 프로바이더에만 존재하는* 기능만 id 로 조건 분기한다. 범용 경로에 `== "claude_code"` 류 리터럴 분기를 추가하는 건 금지.