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
27 changes: 17 additions & 10 deletions Sources/PokeTokenBar/Core/LocalUsageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ struct LocalCodexProvider: UsageProvider {
func fetchDaily() async throws -> DailyUsage? {
let now = Date()
let entries = await LocalUsageCache.shared.codexEntries(modifiedSince: Calendar.current.startOfDay(for: now))
guard let d = LocalUsageReader.daily(entries: entries, localDay: LocalUsageReader.todayKey()) else { return nil }
return DailyUsage(date: d.date, inputTokens: d.inputTokens, outputTokens: d.outputTokens,
cacheCreationTokens: d.cacheCreationTokens, cacheReadTokens: d.cacheReadTokens,
totalTokens: d.totalTokens, totalCost: 0)
// 구독제라 비용은 0. 재조립 대신 mutate — 새 필드가 조용히 유실되지 않게(per-model 은 미opt-in).
guard var d = LocalUsageReader.daily(entries: entries, localDay: LocalUsageReader.todayKey()) else { return nil }
d.totalCost = 0
return d
}

func fetchEnrichment() async -> ProviderEnrichment {
Expand Down Expand Up @@ -175,23 +175,30 @@ struct LocalPiProvider: UsageProvider {
let id = "pi"
let displayName = "Pi"
let reportsCost = false
/// 캐시 시임 — 기본은 공용 캐시(실 로그), 테스트만 픽스처 루트를 주입한다.
let cache: LocalUsageCache

init(cache: LocalUsageCache = .shared) { self.cache = cache }

func fetchDaily() async throws -> DailyUsage? {
let now = Date()
let entries = await LocalUsageCache.shared.piEntries(
let entries = await cache.piEntries(
modifiedSince: Calendar.current.startOfDay(for: now))
guard let d = LocalUsageReader.daily(entries: entries, localDay: LocalUsageReader.todayKey()) else {
// Pi 는 forks(OMP 등)가 여러 모델을 한 세션 로그로 흘리므로 per-model 내역을 켠다.
guard var d = LocalUsageReader.daily(
entries: entries, localDay: LocalUsageReader.todayKey(), includeModels: true) else {
return nil
}
return DailyUsage(date: d.date, inputTokens: d.inputTokens, outputTokens: d.outputTokens,
cacheCreationTokens: d.cacheCreationTokens, cacheReadTokens: d.cacheReadTokens,
totalTokens: d.totalTokens, totalCost: 0)
// 플랫요금 취급 — 실 모델 id 는 이제 단가표에 걸리므로 여기서 비용을 0 으로 눌러야 한다
// (`reportsCost = false` 로 UI 에도 안 뜬다). 필드 재조립 대신 mutate 해 새 필드 유실을 막는다.
d.totalCost = 0
return d
}

func fetchEnrichment() async -> ProviderEnrichment {
let now = Date()
let monthStart = LocalUsageReader.startOfMonth(now)
let entries = await LocalUsageCache.shared.piEntries(
let entries = await cache.piEntries(
modifiedSince: LocalUsageReader.enrichmentScanStart(now: now))
let fmt = LocalUsageReader.localDayFormatter()
var result = ProviderEnrichment()
Expand Down
22 changes: 16 additions & 6 deletions Sources/PokeTokenBar/Core/LocalUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ enum LocalUsageReader {

let usage: [String: Any]?
let date: Date?
// OMP/Pi forks write the model at envelope level; vanilla Pi nests it in the message.
var model = "pi"
switch type {
case "message":
guard let message = envelope["message"] as? [String: Any],
Expand All @@ -424,14 +426,16 @@ enum LocalUsageReader {
let messageUsage = message["usage"] as? [String: Any] else { return }
usage = messageUsage
date = piMessageDate(message, envelope: envelope)
model = (envelope["model"] as? String)
?? (message["model"] as? String) ?? "pi"
case "compaction", "branch_summary":
usage = envelope["usage"] as? [String: Any]
date = piEnvelopeDate(envelope)
default:
return
}
guard let usage, let date,
let entry = piEntry(id: id, date: date, usage: usage, fmt: fmt) else { return }
let entry = piEntry(id: id, date: date, usage: usage, model: model, fmt: fmt) else { return }
out.append(entry)
}
}
Expand All @@ -450,7 +454,7 @@ enum LocalUsageReader {
}

private static func piEntry(
id: String, date: Date, usage: [String: Any], fmt: DateFormatter
id: String, date: Date, usage: [String: Any], model: String, fmt: DateFormatter
) -> Entry? {
let names = ["input", "output", "cacheWrite", "cacheRead"]
let hasGranularUsage = names.contains { intOrNil(usage[$0]) != nil }
Expand All @@ -473,7 +477,7 @@ enum LocalUsageReader {
return nil
}
return Entry(
id: id, date: date, localDay: fmt.string(from: date), model: "pi",
id: id, date: date, localDay: fmt.string(from: date), model: model,
input: input, output: output, cacheWrite: cacheWrite, cacheRead: cacheRead)
}

Expand Down Expand Up @@ -1411,13 +1415,19 @@ enum LocalUsageReader {
// MARK: 집계

/// 특정 로컬 날짜의 합계 → DailyUsage. 해당 날짜 데이터 없으면 nil.
static func daily(entries: [Entry], localDay: String) -> DailyUsage? {
/// `includeModels` 를 켠 프로바이더만 per-model 내역을 채운다 — 끄면 `models` 는 nil 이라
/// 팝오버의 per-model 행이 그 프로바이더에서는 뜨지 않는다(현재는 Pi 만 opt-in).
static func daily(entries: [Entry], localDay: String, includeModels: Bool = false) -> DailyUsage? {
var b = Bucket()
for e in entries where e.localDay == localDay { b.add(e) }
var models: [String: Int]? = includeModels ? [:] : nil
for e in entries where e.localDay == localDay {
b.add(e)
if includeModels { models?[e.model, default: 0] += e.total }
}
guard b.total > 0 else { return nil }
return DailyUsage(date: localDay, inputTokens: b.input, outputTokens: b.output,
cacheCreationTokens: b.cacheWrite, cacheReadTokens: b.cacheRead,
totalTokens: b.total, totalCost: b.cost)
totalTokens: b.total, totalCost: b.cost, models: models)
}

/// 로컬 날짜 [start, end] (포함) 범위 합계 → PeriodUsage.
Expand Down
9 changes: 7 additions & 2 deletions Sources/PokeTokenBar/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ struct DailyUsage: Decodable, Sendable {
var cacheReadTokens: Int
var totalTokens: Int
var totalCost: Double
/// totalTokens per source model when the provider reports it (nil otherwise).
var models: [String: Int]?

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
Expand All @@ -26,22 +28,25 @@ struct DailyUsage: Decodable, Sendable {
?? (inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens)
totalCost = try c.decodeIfPresent(Double.self, forKey: .totalCost)
?? c.decodeIfPresent(Double.self, forKey: .costUSD) ?? 0
models = try c.decodeIfPresent([String: Int].self, forKey: .models)
}

init(date: String, inputTokens: Int, outputTokens: Int,
cacheCreationTokens: Int, cacheReadTokens: Int, totalTokens: Int, totalCost: Double) {
cacheCreationTokens: Int, cacheReadTokens: Int, totalTokens: Int, totalCost: Double,
models: [String: Int]? = nil) {
self.date = date
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheCreationTokens = cacheCreationTokens
self.cacheReadTokens = cacheReadTokens
self.totalTokens = totalTokens
self.totalCost = totalCost
self.models = models
}

private enum CodingKeys: String, CodingKey {
case date, period, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens
case cachedInputTokens, totalTokens, totalCost, costUSD
case cachedInputTokens, totalTokens, totalCost, costUSD, models
}
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/PokeTokenBar/UI/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,22 @@ struct PopoverView: View {
tokenTypeLabel("cache w", today.cacheCreationTokens)
tokenTypeLabel("cache r", today.cacheReadTokens)
}
if let models = today.models, models.count > 1 {
ForEach(models.sorted(by: { $0.value > $1.value }), id: \.key) { model, tokens in
HStack(spacing: 6) {
Text(model.split(separator: "/").last.map(String.init) ?? model)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(TokenFormatter.compact(tokens))
.font(.caption2)
.foregroundStyle(.tertiary)
.monospacedDigit()
}
}
}
}
.padding(.top, 2)
}
Expand Down
78 changes: 77 additions & 1 deletion Tests/PokeTokenBarTests/PiUsageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ final class PiUsageTests: XCTestCase {

let entry = try XCTUnwrap(parsed(url).first)
XCTAssertEqual(entry.id, "turn-1")
XCTAssertEqual(entry.model, "pi")
XCTAssertEqual(entry.model, "model-name", "Pi entries carry the real model id for pricing and per-model breakdown")
XCTAssertEqual(entry.input, 10)
XCTAssertEqual(entry.output, 20, "Pi reasoning is a subset of output")
XCTAssertEqual(entry.cacheWrite, 4)
Expand Down Expand Up @@ -208,3 +208,79 @@ final class PiUsageTests: XCTestCase {
XCTAssertFalse(provider.reportsCost)
}
}

extension PiUsageTests {
func testPiAttributesModelFromEnvelopeAndMessageAndBreaksDownDailyByModel() throws {
// OMP-style: model at envelope level.
let ompMessage = [
"type": "message",
"id": "omp-1",
"timestamp": "2026-08-17T10:00:00.000Z",
"model": "openrouter/stealth/ox-alpha",
"message": [
"role": "assistant",
"content": [],
"usage": usage(input: 100, output: 200, totalTokens: nil),
],
] as [String: Any]
// Vanilla Pi style: model nested in the message.
let piMessage = message(id: "pi-1", usage: usage(input: 10, output: 20, totalTokens: nil))

let url = try write([ompMessage, piMessage])
let entries = try parsed(url)
XCTAssertEqual(entries.count, 2)
XCTAssertEqual(entries.first { $0.id == "omp-1" }?.model, "openrouter/stealth/ox-alpha")
XCTAssertEqual(entries.first { $0.id == "pi-1" }?.model, "model-name")

let day = "2026-08-17"
let daily = try XCTUnwrap(
LocalUsageReader.daily(entries: entries, localDay: day, includeModels: true))
XCTAssertEqual(daily.totalTokens, 330)
let models = try XCTUnwrap(daily.models)
XCTAssertEqual(models["openrouter/stealth/ox-alpha"], 300)
XCTAssertEqual(models["model-name"], 30)

XCTAssertNil(
LocalUsageReader.daily(entries: entries, localDay: day)?.models,
"daily() gates the per-model breakdown behind includeModels so only Pi opts in")
}

/// The defect lives one layer up from `daily`: `LocalPiProvider` repackages the result and
/// must carry the per-model breakdown through (while still zeroing the flat-rate cost). A
/// fixture routed through `fetchDaily()` covers that repackaging step, not just aggregation.
func testLocalPiProviderFetchDailyForwardsBreakdownAndZeroesCost() async throws {
let now = Date()
let ms = now.timeIntervalSince1970 * 1_000
let iso = Self.isoUTC.string(from: now)
// OMP-style: model at envelope level, date from the envelope timestamp.
let omp = [
"type": "message", "id": "omp-1", "timestamp": iso,
"model": "openrouter/stealth/ox-alpha",
"message": ["role": "assistant", "content": [],
"usage": usage(input: 100, output: 200)],
] as [String: Any]
// Vanilla Pi style: model nested in the message, date from the message timestamp.
let pi = message(id: "pi-1", envelopeTimestamp: iso, messageTimestamp: ms,
usage: usage(input: 10, output: 20))
try write([omp, pi])

let cacheFile = base.appendingPathComponent("cache.json")
let provider = LocalPiProvider(cache: LocalUsageCache(piRoots: [rootA], fileURL: cacheFile))
let fetched = try await provider.fetchDaily()
let daily = try XCTUnwrap(fetched)

XCTAssertEqual(daily.totalTokens, 330)
XCTAssertEqual(daily.totalCost, 0, "Pi is flat-rate even though real model ids now price")
let models = try XCTUnwrap(daily.models, "the breakdown must survive provider repackaging")
XCTAssertEqual(models["openrouter/stealth/ox-alpha"], 300)
XCTAssertEqual(models["model-name"], 30)
}

private static let isoUTC: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
return f
}()
}