Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public struct AntigravityRateLimitsProvider: AntigravityLimitsProviding, Sendabl
public init() {}

public func fetch(allowKeychainPrompt: Bool = false) async throws -> AntigravityRateLimitStatus {
// 테스트가 사용자의 실제 토큰으로 원격을 부르지 않게 — `AppEnv.allowsLiveLimitsFetch` 참조.
// Keychain 읽기보다 **먼저** 막는다: 막아야 할 것은 네트워크만이 아니라 자격증명 접근 자체다.
guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.keychainAccessDisabled }
let token = try await tokenCache.accessToken(allowKeychainPrompt: allowKeychainPrompt)
do {
return try await fetchStatus(accessToken: token)
Expand Down
34 changes: 34 additions & 0 deletions Sources/PokeTokenBar/Core/AppEnv.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,38 @@ enum AppEnv {
static var isBundledApp: Bool {
Bundle.main.bundleIdentifier != nil && Bundle.main.bundlePath.hasSuffix(".app")
}

/// 저장소가 자기 파일을 읽고 써도 되는가.
///
/// 경로를 **주입했으면** 언제나 허용한다 — 테스트가 임시 파일로 영속화를 검증하는 방식이다. 주입하지
/// 않아 **기본(사용자) 경로**로 떨어진 저장소는 실앱에서만 영속화한다. 그래야 `swift test` 가 사용자의
/// 실제 파일을 건드리지 않는다.
///
/// 왜 저장소 쪽에 두는가: 테스트가 경로를 주입해서는 못 막는다. 주입은 그 테스트만 덮고 **프로세스**는
/// 못 덮으므로, 기본 경로로 가는 분기는 테스트에서 보이지 않는다. 실측(2026-09-29, 앱 종료 상태):
/// 전체 테스트 한 번에 사용자 실제 `usage-cache.json` 이 726,929 → 763,881 바이트로 바뀌었다 —
/// 이 세션 내내 테스트를 돌릴 때마다 사용자 데이터를 고쳐 쓰고 있었다(상류 d6d24a04 와 같은 결함).
/// 규칙은 여기 한 곳에만 둔다 — 저장소마다 복사하면 다음 복사본에서 빠뜨린다.
/// 한도 프로바이더가 **실제** 자격증명을 읽고 원격 엔드포인트를 불러도 되는가.
///
/// `UsageStore` 는 한도 프로바이더를 기본값으로 **진짜** 것(`OAuthLimitsProvider()` 등)을 쓰고, 스텁을
/// 주입하지 않은 테스트가 열 개쯤 있다(실측). 그 테스트가 refresh 를 돌리면, 마침 Keychain 승인이 살아
/// 있는 순간에는 무UI 읽기가 성공해 **사용자의 실제 토큰으로 api.anthropic.com 을 부른다** — 사용자의
/// 한도 요청량을 소모하고(실앱이 그 뒤 429 백오프를 맞는다), Antigravity 면 refresh 토큰까지 쓴다.
///
/// 실앱에서만 허용하고, 개발자가 **일부러** 실측하려면 `PTB_PARITY=1` 로 켠다(로컬 패리티 테스트와 같은
/// 스위치). 스텁을 주입한 테스트는 이 게이트를 지나지 않으므로 영향이 없다.
static var allowsLiveLimitsFetch: Bool {
allowLiveLimitsFetchForTesting || isBundledApp || ProcessInfo.processInfo.environment["PTB_PARITY"] == "1"
}

/// 키체인 규율 테스트 전용 opt-in. 반드시 `KeychainReader.stubStatusForTesting` 과 **같이** 켠다 —
/// 게이트만 열면 프로바이더가 실제 키체인을 읽는다. 게이트를 닫아 둔 채면 프로바이더 로직이 아예 안
/// 돌아서, "자동 경로는 키체인을 안 읽는다"가 프로바이더의 규율이 아니라 게이트 때문에 참이 된다.
nonisolated(unsafe) static var allowLiveLimitsFetchForTesting = false

static func persistsToUserLocation(injectedFileURL: URL?,
isBundledApp: Bool = AppEnv.isBundledApp) -> Bool {
injectedFileURL != nil || isBundledApp
}
}
6 changes: 6 additions & 0 deletions Sources/PokeTokenBar/Core/KeychainAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ enum KeychainReader {

static func resetQueryCountForTesting() { queryCount = 0 }

/// 테스트용 대체 백엔드. 설정하면 `SecItemCopyMatching` 대신 이 값을 돌려준다 — 조회 **횟수**는
/// 그대로 세면서 실제 키체인에는 닿지 않는다. 키체인 규율 테스트가 사용자의 실제 키체인을 읽고
/// (`allowKeychainPrompt: true` 면 프롬프트까지 띄우며) 돌던 것을 막는다.
nonisolated(unsafe) static var stubStatusForTesting: OSStatus?

static func copyMatching(_ query: [String: Any], _ result: inout CFTypeRef?) -> OSStatus {
queryCount += 1
if let stub = stubStatusForTesting { return stub }
return SecItemCopyMatching(query as CFDictionary, &result)
}
}
Expand Down
16 changes: 14 additions & 2 deletions Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1115,10 +1115,22 @@ enum LocalAdditionalUsageReader {
return trimmed.isEmpty ? nil : trimmed
}

/// 외부 파일(커서 DB·Kiro·OpenCode 로그)의 토큰 수 — 음수는 0, 상한은 `LocalUsageReader.maxParsedTokenValue`.
///
/// 이 함수는 `LocalUsageReader.intValue` 의 **별도 사본**이다. 그쪽은 합산 오버플로 크래시 뒤에 상한을
/// 얻었지만(defect-log '외부에서 오는 모든 수치'), 이 사본은 하한만 있고 상한이 없었다. `NSNumber.intValue`
/// 는 `Int.max` 로 포화하므로, 손상된 값 하나가 합산에서 `+` 오버플로 트랩을 낸다 — 커서 로컬 DB 의
/// `tokenCount` 가 정확히 이 경로를 탄다(`parseCursorBubble`). 상류 #307 과 같은 수정.
/// 사본이 둘이면 수정도 두 번이다. 상한은 새로 만들지 않고 그쪽 값을 그대로 쓴다.
private static func intValue(_ value: Any?) -> Int {
if let number = value as? NSNumber { return max(0, number.intValue) }
let cap = LocalUsageReader.maxParsedTokenValue
if let number = value as? NSNumber {
let d = number.doubleValue
guard d.isFinite, d > 0 else { return 0 }
return d >= Double(cap) ? cap : Int(d)
}
if let string = value as? String, let number = Int(string.trimmingCharacters(in: .whitespaces)) {
return max(0, number)
return min(max(0, number), cap)
}
return 0
}
Expand Down
7 changes: 5 additions & 2 deletions Sources/PokeTokenBar/Core/LocalUsageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ actor LocalUsageCache {
private let grokRoots: [URL]?
private let piRoots: [URL]?
private let fileURL: URL
/// false 면 디스크를 읽지도 쓰지도 않는다 — `AppEnv.persistsToUserLocation` 참조.
private let persists: Bool
private let now: @Sendable () -> Date
/// throwing probe 를 쓴다 — 읽기 실패(throw)와 "metadata 없음"(`nil`)은 인덱스에 남길지가 다르다.
private let codexProbe: @Sendable (URL) throws -> String?
Expand Down Expand Up @@ -132,6 +134,7 @@ actor LocalUsageCache {
self.grokRoots = grokRoots
self.piRoots = piRoots
self.fileURL = fileURL ?? Self.defaultFileURL
self.persists = AppEnv.persistsToUserLocation(injectedFileURL: fileURL)
self.now = now
self.codexProbe = codexProbe
}
Expand Down Expand Up @@ -364,7 +367,7 @@ actor LocalUsageCache {
private func ensureLoaded() {
guard !loaded else { return }
loaded = true
guard let raw = try? Data(contentsOf: fileURL) else { return }
guard persists, let raw = try? Data(contentsOf: fileURL) else { return }
// zlib 압축 스냅샷(현행) → 실패 시 평문 JSON(구버전 캐시) 폴백
let data = (try? (raw as NSData).decompressed(using: .zlib) as Data) ?? raw
guard let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return }
Expand Down Expand Up @@ -407,7 +410,7 @@ actor LocalUsageCache {

/// 변경이 있으면 디스크에 저장(최소 60초 간격으로 throttle — 잦은 쓰기 방지).
private func saveIfNeeded() {
guard dirty else { return }
guard persists, dirty else { return }
if let last = lastSave, now().timeIntervalSince(last) < 60 { return }
prune()
let snap = Snapshot(
Expand Down
6 changes: 5 additions & 1 deletion Sources/PokeTokenBar/Core/LocalUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,11 @@ enum LocalUsageReader {
/// 최근 5시간 롤링 윈도우 기반 활성 블록(번 레이트 추정용).
static func activeBlock(entries: [Entry], now: Date) -> BlockUsage? {
let windowStart = now.addingTimeInterval(-blockWindow)
let recent = entries.filter { $0.date >= windowStart }.sorted { $0.date < $1.date }
// Claude Code can write `<synthetic>` assistant records whose usage fields are all zero
// even when no Claude request ran (for example, a local wrapper/session bootstrap). Those
// records are parser-valid metadata, not usage: exclude them from both existence and the
// block start time so they cannot create or stretch a carrier snapshot/tab.
let recent = entries.filter { $0.date >= windowStart && $0.total > 0 }.sorted { $0.date < $1.date }
guard let first = recent.first else { return nil }
var b = Bucket()
for e in recent { b.add(e) }
Expand Down
9 changes: 9 additions & 0 deletions Sources/PokeTokenBar/Core/ModelPricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ enum ModelPricing {
"claude-opus-4-7": .perMillion(5, 25, 6.25, 0.5),
"claude-sonnet-4-6": .perMillion(3, 15, 3.75, 0.3),
"claude-haiku-4-5-20251001": .perMillion(1, 5, 1.25, 0.1),
// Claude 5 계열 — 명시 행(상류 #304·Opus 5.5 행). 패밀리 폴백이 이 포크엔 남아 있어 "Unavailable"은
// 안 됐지만, 폴백 단가가 **틀린** 모델이 있었다: `claude-opus-5-5` 는 폴백이 Opus 5 단가
// (5, 25, 6.25, 0.5)를 매겼는데 실제는 (4, 20, 5, 0.2) — 입력·출력·캐시쓰기 25%, **캐시 읽기 2.5배**
// 과대계상. 캐시 읽기가 실사용의 대부분이라 총액이 크게 부풀었다. `claude-sonnet-5` 도 폴백이
// Sonnet 4 단가로 1.5배 과대. `claude-opus-5` 는 폴백과 같은 값이지만 명시해 둔다(폴백 변경에 안 흔들리게).
"claude-opus-5": .perMillion(5, 25, 6.25, 0.5),
"claude-opus-5-5": .perMillion(4, 20, 5, 0.2),
"claude-sonnet-5": .perMillion(2, 10, 2.5, 0.2),
"claude-fable-5": .perMillion(10, 50, 12.5, 1.0), // LiteLLM 스냅샷 가격 등재됨(2026-08) — 기존 미가격 $0 플레이스홀더 대체
// Fable 5.1: Fable 5 와 기본 단가는 같지만 cache read 만 $0.25/MTok(입력의 0.025배)로 내려갔다.
// 이 줄이 없으면 `fable` 패밀리 폴백이 Fable 5 의 $1.00 을 적용해 **비용을 4배 부풀린다**(상류 #277).
"claude-fable-5-1": .perMillion(10, 50, 12.5, 0.25),
"gpt-5.5": .perMillion(5, 30, 0, 0.5),
// Gemini — 공식 API 단가(기본 티어, ≤200K 프롬프트). 캐시는 read 단가만(스토리지 시간요금 제외).
"gemini-2.5-flash-lite": .perMillion(0.10, 0.40, 0, 0.01), // 상류 #309
"gemini-2.5-pro": .perMillion(1.25, 10, 0, 0.3125),
"gemini-2.5-flash": .perMillion(0.30, 2.5, 0, 0.075),
"gemini-2.0-flash": .perMillion(0.10, 0.4, 0, 0.025),
Expand Down
3 changes: 3 additions & 0 deletions Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ struct OAuthLimitsProvider: ClaudeLimitsProviding, Sendable {
}()

func fetch(allowKeychainPrompt: Bool = false) async throws -> LimitStatus {
// 테스트가 사용자의 실제 토큰으로 원격을 부르지 않게 — `AppEnv.allowsLiveLimitsFetch` 참조.
// Keychain 읽기보다 **먼저** 막는다: 막아야 할 것은 네트워크만이 아니라 자격증명 접근 자체다.
guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.keychainAccessDisabled }
let token = try await accessTokenCache.accessToken(allowKeychainPrompt: allowKeychainPrompt)
var status: LimitStatus
do {
Expand Down
3 changes: 2 additions & 1 deletion Sources/PokeTokenBar/Core/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,8 @@ final class UsageStore {
// 누적만으로 만들면, weekTotal 이 옵셔널이 아니라(토큰 0이어도 non-nil) 오늘·최근
// 미사용 프로바이더까지 탭이 떠서 "안 썼는데 왜 뜨지" 회귀가 난다. 블록이 있을 때만
// 그 시점의 주/월도 함께 보존한다.
let hasActiveBlock = enrichment.blocksOK && enrichment.activeBlock != nil
let hasActiveBlock = enrichment.blocksOK
&& (enrichment.activeBlock?.totalTokens ?? 0) > 0
if hasActiveBlock, let provider = providers.first(where: { $0.id == id }) {
snapshots.append(ProviderSnapshot(
providerID: id, displayName: provider.displayName, today: nil,
Expand Down
11 changes: 10 additions & 1 deletion Tests/PokeTokenBarTests/BattleStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,16 @@ final class BattleStoreNetworkTests: XCTestCase {
let session = URLSession(configuration: config)
let online = OnlineStore(defaults: UserDefaults(suiteName: "BattleStoreNetworkTests.\(UUID().uuidString)")!, session: session)
online.serverURL = "https://mock.test"
return BattleStore(companion: companion, online: online, session: session, pollIntervalNanoseconds: pollIntervalNanoseconds)
let store = BattleStore(companion: companion, online: online, session: session, pollIntervalNanoseconds: pollIntervalNanoseconds)
// SpectatorStoreTests 와 같은 누수: 멈추지 않은 스토어가 다음 테스트가 정적 공유 큐에 넣은 응답을
// 먼저 가져간다. 거기서 CI 간헐 실패로 드러났고, 같은 패턴을 복사한 이 파일은 스토어를 하나도 안
// 멈추고 있었다(아직 터지지 않았을 뿐). 부류째 고친다.
addTeardownBlock { await store.cancel() }
return store
}

override func setUp() async throws {
QueuedStubURLProtocol.responses = []
}

/// [Mirrors TradeStoreFailureTests] A 401 must not be a dead end — self-heals back to idle.
Expand Down
8 changes: 8 additions & 0 deletions Tests/PokeTokenBarTests/GeminiUsageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ final class GeminiUsageTests: XCTestCase {
// 통과하지만, cache read 만 $1.00 → $0.25 로 내려가 있다. 명시 행이 없으면 캐시 비용을
// **4배** 부풀린다 — 캐시 읽기가 대부분인 실사용에서 총액이 눈에 띄게 틀어진다(상류 #277).
XCTAssertEqual(ModelPricing.rate(for: "claude-fable-5-1"), .perMillion(10, 50, 12.5, 0.25))
// Opus 5.5 는 폴백(`contains("opus")` → Opus 5 단가)이 **틀린** 값을 매기던 모델이다. 명시 행이
// 없으면 캐시 읽기가 0.5 로 새어 2.5배 과대가 된다 — 캐시 읽기가 대부분인 실사용에서 총액이 부푼다.
XCTAssertEqual(ModelPricing.rate(for: "claude-opus-5-5"), .perMillion(4, 20, 5, 0.2))
XCTAssertEqual(ModelPricing.cost(model: "claude-opus-5-5", input: 0, output: 0,
cacheWrite: 0, cacheRead: 1_000_000), 0.2,
accuracy: 0.0001, "폴백으로 새면 0.5 가 된다")
XCTAssertEqual(ModelPricing.rate(for: "claude-sonnet-5"), .perMillion(2, 10, 2.5, 0.2),
"폴백은 Sonnet 4 단가(3,15)로 1.5배 과대")
XCTAssertEqual(ModelPricing.cost(model: "claude-fable-5-1", input: 0, output: 0,
cacheWrite: 0, cacheRead: 1_000_000), 0.25,
accuracy: 0.0001, "패밀리 폴백으로 새면 1.00 이 된다")
Expand Down
8 changes: 8 additions & 0 deletions Tests/PokeTokenBarTests/KeychainAutoPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class KeychainAutoPathTests: XCTestCase {
let savedGate = KeychainAccessGate.isDisabled
KeychainAccessGate.isDisabled = false
defer { KeychainAccessGate.isDisabled = savedGate }
// 게이트를 열되 키체인은 가짜로 — 프로바이더의 실제 규율을 돌리면서 사용자 키체인은 안 건드린다.
AppEnv.allowLiveLimitsFetchForTesting = true
KeychainReader.stubStatusForTesting = errSecItemNotFound
defer { AppEnv.allowLiveLimitsFetchForTesting = false; KeychainReader.stubStatusForTesting = nil }

for probe in Self.automaticProbes {
KeychainReader.resetQueryCountForTesting()
Expand All @@ -40,6 +44,10 @@ final class KeychainAutoPathTests: XCTestCase {
let savedGate = KeychainAccessGate.isDisabled
KeychainAccessGate.isDisabled = false
defer { KeychainAccessGate.isDisabled = savedGate }
// 게이트를 열되 키체인은 가짜로 — 프로바이더의 실제 규율을 돌리면서 사용자 키체인은 안 건드린다.
AppEnv.allowLiveLimitsFetchForTesting = true
KeychainReader.stubStatusForTesting = errSecItemNotFound
defer { AppEnv.allowLiveLimitsFetchForTesting = false; KeychainReader.stubStatusForTesting = nil }

KeychainReader.resetQueryCountForTesting()
_ = try? await AntigravityRateLimitsProvider().fetch(allowKeychainPrompt: true)
Expand Down
22 changes: 22 additions & 0 deletions Tests/PokeTokenBarTests/LocalUsageReaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,28 @@ final class LocalUsageReaderTests: XCTestCase {
XCTAssertGreaterThanOrEqual(p.totalTokens, 600_000)
}

/// Claude writes parser-valid `<synthetic>` assistant rows with every usage field set to zero.
/// They are metadata, not evidence that the provider is active, so they must not create the
/// active-block carrier snapshot that makes a zero-usage Claude Code tab appear.
func testActiveBlockIgnoresZeroTokenSyntheticEntries() {
let now = Date()
let synthetic = LocalUsageReader.Entry(
id: "synthetic", date: now.addingTimeInterval(-120),
localDay: LocalUsageReader.localDayFormatter().string(from: now),
model: "<synthetic>", input: 0, output: 0, cacheWrite: 0, cacheRead: 0)

XCTAssertNil(LocalUsageReader.activeBlock(entries: [synthetic], now: now))

let actual = LocalUsageReader.Entry(
id: "actual", date: now.addingTimeInterval(-60),
localDay: LocalUsageReader.localDayFormatter().string(from: now),
model: "claude-opus", input: 10, output: 5, cacheWrite: 0, cacheRead: 0)
let block = LocalUsageReader.activeBlock(entries: [synthetic, actual], now: now)
XCTAssertEqual(block?.totalTokens, 15)
XCTAssertEqual(block?.id, "block-\(Int(actual.date.timeIntervalSince1970))",
"zero-token metadata must not move the active block start earlier")
}

// MARK: enrichment 스캔 하한 (월초 경계 흡수)

/// 스캔 하한은 블록(now-5h)·이번 주(weekStart)·이번 달(monthStart) 세 윈도우 시작을 모두 덮어야
Expand Down
Loading
Loading