Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Sources/PokeTokenBar/Core/LocalUsageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
22 changes: 22 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions Sources/PokeTokenBar/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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 }
Expand Down
5 changes: 5 additions & 0 deletions Sources/PokeTokenBar/Core/TokenFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
8 changes: 8 additions & 0 deletions Sources/PokeTokenBar/Core/UsageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -18,6 +25,7 @@ protocol UsageProvider: Sendable {

extension UsageProvider {
var reportsCost: Bool { true }
var costIsEstimate: Bool { true }
}

/// 부가 정보 수집 결과. *OK 플래그가 false 면 수집 실패 → 이전 값 유지.
Expand Down
42 changes: 40 additions & 2 deletions Sources/PokeTokenBar/Core/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -683,7 +719,8 @@ final class UsageStore {
weekTotal: prevWeek,
monthTotal: prevMonth,
fetchedAt: Date(),
reportsCost: provider.reportsCost))
reportsCost: provider.reportsCost,
costIsEstimate: provider.costIsEstimate))
}
}
snapshots = newSnapshots
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/PokeTokenBar/UI/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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개 이상이면 작은 탭으로 서비스별 상세를 넘나든다
// (합계는 위에 유지 — 상세·한도만 탭 스코프).
Expand Down
29 changes: 29 additions & 0 deletions Sources/PokeTokenBar/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ struct SettingsView: View {
@State private var customScanMatchTask: Task<Void, Never>?
@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 }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions Tests/PokeTokenBarTests/PokeTokenBarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down
Loading