871 lines
33 KiB
Swift
871 lines
33 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct CostUsageFetcherTests {
|
|
@Test
|
|
func `fetcher scopes codex history to selected codex home`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
let otherHome = env.root.appendingPathComponent("other-codex-home", isDirectory: true)
|
|
try Self.writeCodexSessionFile(
|
|
homeRoot: env.codexHomeRoot,
|
|
env: env,
|
|
day: day,
|
|
filename: "ambient.jsonl",
|
|
tokens: 100)
|
|
try Self.writeCodexSessionFile(homeRoot: otherHome, env: env, day: day, filename: "managed.jsonl", tokens: 10)
|
|
|
|
let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot)
|
|
let ambient = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
scannerOptions: options)
|
|
let managed = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
codexHomePath: otherHome.path,
|
|
scannerOptions: options)
|
|
|
|
#expect(ambient.sessionTokens == 100)
|
|
#expect(managed.sessionTokens == 10)
|
|
}
|
|
|
|
@Test
|
|
func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
let managedHome = env.root.appendingPathComponent("managed-codex-home", isDirectory: true)
|
|
try Self.writeCodexSessionFile(
|
|
homeRoot: env.codexHomeRoot,
|
|
env: env,
|
|
day: day,
|
|
filename: "ambient.jsonl",
|
|
tokens: 100)
|
|
try Self.writeCodexSessionFile(homeRoot: managedHome, env: env, day: day, filename: "managed.jsonl", tokens: 10)
|
|
|
|
let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot)
|
|
let piOptions = PiSessionCostScanner.Options(piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot)
|
|
let ambient = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
scannerOptions: options,
|
|
piScannerOptions: piOptions)
|
|
#expect(ambient.sessionTokens == 100)
|
|
|
|
var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
|
|
cache.roots = nil
|
|
CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot)
|
|
|
|
let managed = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day.addingTimeInterval(1),
|
|
codexHomePath: managedHome.path,
|
|
scannerOptions: options,
|
|
piScannerOptions: piOptions)
|
|
|
|
#expect(managed.sessionTokens == 10)
|
|
}
|
|
|
|
@Test
|
|
func `fetcher refreshes codex cache when history window expands`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let oldDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2)
|
|
let newDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
try Self.writeCodexSessionFile(
|
|
homeRoot: env.codexHomeRoot,
|
|
env: env,
|
|
day: oldDay,
|
|
filename: "old.jsonl",
|
|
tokens: 15)
|
|
try Self.writeCodexSessionFile(
|
|
homeRoot: env.codexHomeRoot,
|
|
env: env,
|
|
day: newDay,
|
|
filename: "new.jsonl",
|
|
tokens: 30)
|
|
|
|
var options = CostUsageScanner.Options(cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 3600
|
|
|
|
let narrow = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: newDay,
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 1,
|
|
scannerOptions: options)
|
|
#expect(narrow.daily.map(\.date) == ["2026-04-08"])
|
|
#expect(narrow.last30DaysTokens == 30)
|
|
|
|
var legacyCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
|
|
legacyCache.scanSinceKey = nil
|
|
legacyCache.scanUntilKey = nil
|
|
CostUsageCacheIO.save(provider: .codex, cache: legacyCache, cacheRoot: env.cacheRoot)
|
|
|
|
let expanded = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: newDay.addingTimeInterval(1),
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 7,
|
|
scannerOptions: options)
|
|
#expect(expanded.daily.map(\.date) == ["2026-04-02", "2026-04-08"])
|
|
#expect(expanded.last30DaysTokens == 45)
|
|
}
|
|
|
|
@Test
|
|
func `fetcher resolves fork parent outside requested codex window`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let parentDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2)
|
|
let childDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
let model = "openai/gpt-5.4"
|
|
let parentID = "parent-session"
|
|
let parentTimestamp = env.isoString(for: parentDay.addingTimeInterval(1))
|
|
let childTimestamp = env.isoString(for: childDay.addingTimeInterval(1))
|
|
_ = try env.writeCodexSessionFile(
|
|
day: parentDay,
|
|
filename: "parent.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "session_meta",
|
|
"timestamp": env.isoString(for: parentDay),
|
|
"payload": ["session_id": parentID],
|
|
],
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": parentTimestamp,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": model,
|
|
"total_token_usage": [
|
|
"input_tokens": 100,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
_ = try env.writeCodexSessionFile(
|
|
day: childDay,
|
|
filename: "child.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "session_meta",
|
|
"timestamp": env.isoString(for: childDay),
|
|
"payload": [
|
|
"session_id": "child-session",
|
|
"forked_from_id": parentID,
|
|
"timestamp": parentTimestamp,
|
|
],
|
|
],
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": childTimestamp,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": model,
|
|
"total_token_usage": [
|
|
"input_tokens": 125,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 5,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
|
|
let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot)
|
|
let snapshot = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: childDay,
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 1,
|
|
scannerOptions: options)
|
|
|
|
#expect(snapshot.daily.map(\.date) == ["2026-04-08"])
|
|
#expect(snapshot.last30DaysTokens == 30)
|
|
}
|
|
|
|
@Test
|
|
func `force refresh only scans requested codex date window`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let oldDay = try env.makeLocalNoon(year: 2026, month: 3, day: 1)
|
|
let newDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
let oldURL = try env.writeCodexSessionFile(
|
|
day: oldDay,
|
|
filename: "old.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: oldDay),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "openai/gpt-5.4",
|
|
"last_token_usage": [
|
|
"input_tokens": 10,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
try FileManager.default.setAttributes([.modificationDate: oldDay], ofItemAtPath: oldURL.path)
|
|
_ = try env.writeCodexSessionFile(
|
|
day: newDay,
|
|
filename: "new.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: newDay),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "openai/gpt-5.4",
|
|
"last_token_usage": [
|
|
"input_tokens": 30,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
|
|
let options = CostUsageScanner.Options(
|
|
cacheRoot: env.cacheRoot,
|
|
codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"))
|
|
let snapshot = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: newDay,
|
|
forceRefresh: true,
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 1,
|
|
scannerOptions: options)
|
|
let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
|
|
let cacheFileExists = FileManager.default.fileExists(
|
|
atPath: CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot).path)
|
|
|
|
#expect(snapshot.daily.map(\.date) == ["2026-04-08"])
|
|
#expect(snapshot.last30DaysTokens == 30)
|
|
#expect(cacheFileExists)
|
|
#expect(cache.files.keys.sorted().map(URL.init(fileURLWithPath:)).map(\.lastPathComponent) == ["new.jsonl"])
|
|
}
|
|
|
|
@Test
|
|
func `narrow codex refresh preserves wider cache window`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let oldDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2)
|
|
let newDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
_ = try env.writeCodexSessionFile(
|
|
day: oldDay,
|
|
filename: "old.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: oldDay),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "openai/gpt-5.4",
|
|
"last_token_usage": [
|
|
"input_tokens": 15,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
_ = try env.writeCodexSessionFile(
|
|
day: newDay,
|
|
filename: "new.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: newDay),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "openai/gpt-5.4",
|
|
"last_token_usage": [
|
|
"input_tokens": 30,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: env.codexSessionsRoot,
|
|
cacheRoot: env.cacheRoot,
|
|
codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"))
|
|
options.refreshMinIntervalSeconds = 0
|
|
_ = CostUsageScanner.loadDailyReport(
|
|
provider: .codex,
|
|
since: newDay,
|
|
until: newDay,
|
|
now: newDay,
|
|
options: options)
|
|
let wide = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: newDay.addingTimeInterval(1),
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 7,
|
|
refreshPricingInBackground: false,
|
|
scannerOptions: options)
|
|
let narrow = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: newDay.addingTimeInterval(2),
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 1,
|
|
refreshPricingInBackground: false,
|
|
scannerOptions: options)
|
|
let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
|
|
|
|
#expect(wide.last30DaysTokens == 45)
|
|
#expect(narrow.last30DaysTokens == 30)
|
|
#expect(cache.files.keys.map(URL.init(fileURLWithPath:)).map(\.lastPathComponent).sorted() == [
|
|
"new.jsonl",
|
|
"old.jsonl",
|
|
])
|
|
#expect(cache.scanSinceKey == "2026-04-01")
|
|
#expect(cache.scanUntilKey == "2026-04-09")
|
|
}
|
|
|
|
@Test
|
|
func `force codex rescan narrows cache window to refreshed range`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let oldDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2)
|
|
let newDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
_ = try env.writeCodexSessionFile(
|
|
day: oldDay,
|
|
filename: "old.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: oldDay),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "openai/gpt-5.4",
|
|
"last_token_usage": [
|
|
"input_tokens": 15,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
_ = try env.writeCodexSessionFile(
|
|
day: newDay,
|
|
filename: "new.jsonl",
|
|
contents: env.jsonl([
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: newDay),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "openai/gpt-5.4",
|
|
"last_token_usage": [
|
|
"input_tokens": 30,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: env.codexSessionsRoot,
|
|
cacheRoot: env.cacheRoot,
|
|
codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"))
|
|
options.refreshMinIntervalSeconds = 0
|
|
_ = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: newDay,
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 7,
|
|
refreshPricingInBackground: false,
|
|
scannerOptions: options)
|
|
|
|
var rescanOptions = options
|
|
rescanOptions.forceRescan = true
|
|
_ = CostUsageScanner.loadDailyReport(
|
|
provider: .codex,
|
|
since: newDay,
|
|
until: newDay,
|
|
now: newDay.addingTimeInterval(1),
|
|
options: rescanOptions)
|
|
let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
|
|
|
|
#expect(cache.files.keys.map(URL.init(fileURLWithPath:)).map(\.lastPathComponent).sorted() == ["new.jsonl"])
|
|
#expect(cache.scanSinceKey == "2026-04-07")
|
|
#expect(cache.scanUntilKey == "2026-04-09")
|
|
}
|
|
|
|
@Test
|
|
func `codex refresh drops stale cache entry when session moves to archive`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
let contents = try env.jsonl([
|
|
[
|
|
"type": "session_meta",
|
|
"timestamp": env.isoString(for: day),
|
|
"payload": ["session_id": "moved-session"],
|
|
],
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: day.addingTimeInterval(1)),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "openai/gpt-5.4",
|
|
"last_token_usage": [
|
|
"input_tokens": 30,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
])
|
|
let originalURL = try env.writeCodexSessionFile(day: day, filename: "moved.jsonl", contents: contents)
|
|
|
|
var options = CostUsageScanner.Options(cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
let first = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 1,
|
|
scannerOptions: options)
|
|
|
|
let archivedURL = env.codexArchivedSessionsRoot.appendingPathComponent("moved.jsonl", isDirectory: false)
|
|
try FileManager.default.moveItem(at: originalURL, to: archivedURL)
|
|
|
|
let second = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day.addingTimeInterval(1),
|
|
codexHomePath: env.codexHomeRoot.path,
|
|
historyDays: 1,
|
|
scannerOptions: options)
|
|
let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot)
|
|
|
|
#expect(first.last30DaysTokens == 30)
|
|
#expect(second.last30DaysTokens == 30)
|
|
#expect(cache.files.count == 1)
|
|
#expect(cache.files.keys.first.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path } ==
|
|
archivedURL.resolvingSymlinksInPath().path)
|
|
}
|
|
|
|
@Test
|
|
func `fetcher merges native and pi codex history with normalized model names`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
let nativeTurnContext: [String: Any] = [
|
|
"type": "turn_context",
|
|
"timestamp": iso0,
|
|
"payload": [
|
|
"model": "openai/gpt-5.4",
|
|
],
|
|
]
|
|
let nativeTokenCount: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso1,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"total_token_usage": [
|
|
"input_tokens": 100,
|
|
"cached_input_tokens": 20,
|
|
"output_tokens": 10,
|
|
],
|
|
"model": "openai/gpt-5.4",
|
|
],
|
|
],
|
|
]
|
|
_ = try env.writeCodexSessionFile(
|
|
day: day,
|
|
filename: "session.jsonl",
|
|
contents: env.jsonl([nativeTurnContext, nativeTokenCount]))
|
|
|
|
let piAssistant: [String: Any] = [
|
|
"type": "message",
|
|
"timestamp": iso1,
|
|
"message": [
|
|
"role": "assistant",
|
|
"provider": "openai-codex",
|
|
"model": "openai/gpt-5.4",
|
|
"timestamp": Int(day.timeIntervalSince1970 * 1000),
|
|
"usage": [
|
|
"input": 50,
|
|
"cacheRead": 5,
|
|
"output": 5,
|
|
"totalTokens": 60,
|
|
],
|
|
],
|
|
]
|
|
_ = try env.writePiSessionFile(
|
|
relativePath: "2026-04-08T10-00-00-000Z_test.jsonl",
|
|
contents: env.jsonl([piAssistant]))
|
|
|
|
let nativeOptions = CostUsageScanner.Options(
|
|
codexSessionsRoot: env.codexSessionsRoot,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
let piOptions = PiSessionCostScanner.Options(
|
|
piSessionsRoot: env.piSessionsRoot,
|
|
cacheRoot: env.cacheRoot,
|
|
refreshMinIntervalSeconds: 0)
|
|
|
|
let snapshot = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
scannerOptions: nativeOptions,
|
|
piScannerOptions: piOptions)
|
|
|
|
let nativeCost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.4",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10) ?? 0
|
|
let piCost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.4",
|
|
inputTokens: 55,
|
|
cachedInputTokens: 5,
|
|
outputTokens: 5) ?? 0
|
|
|
|
#expect(snapshot.daily.count == 1)
|
|
#expect(snapshot.daily.first?.date == "2026-04-08")
|
|
#expect(snapshot.daily.first?.totalTokens == 170)
|
|
#expect(abs((snapshot.daily.first?.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001)
|
|
let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first)
|
|
#expect(breakdown.modelName == "gpt-5.4")
|
|
#expect(abs((breakdown.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001)
|
|
#expect(breakdown.totalTokens == 170)
|
|
}
|
|
|
|
@Test
|
|
func `fetcher merges native and pi claude history and ignores unsupported pi providers`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 9)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
let nativeAssistant: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"message": [
|
|
"model": "anthropic.foo.claude-sonnet-4-6-v1:0",
|
|
"usage": [
|
|
"input_tokens": 100,
|
|
"cache_creation_input_tokens": 10,
|
|
"cache_read_input_tokens": 5,
|
|
"output_tokens": 20,
|
|
],
|
|
],
|
|
]
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session.jsonl",
|
|
contents: env.jsonl([nativeAssistant]))
|
|
|
|
let supportedPiAssistant: [String: Any] = [
|
|
"type": "message",
|
|
"timestamp": iso1,
|
|
"message": [
|
|
"role": "assistant",
|
|
"provider": "anthropic",
|
|
"model": "claude-sonnet-4-6",
|
|
"timestamp": Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000),
|
|
"usage": [
|
|
"input": 50,
|
|
"cacheRead": 4,
|
|
"cacheWrite": 6,
|
|
"output": 10,
|
|
"totalTokens": 70,
|
|
],
|
|
],
|
|
]
|
|
let unsupportedPiAssistant: [String: Any] = [
|
|
"type": "message",
|
|
"timestamp": iso1,
|
|
"message": [
|
|
"role": "assistant",
|
|
"provider": "openrouter",
|
|
"model": "claude-sonnet-4-6",
|
|
"timestamp": Int(day.addingTimeInterval(120).timeIntervalSince1970 * 1000),
|
|
"usage": [
|
|
"input": 999,
|
|
"output": 1,
|
|
"totalTokens": 1000,
|
|
],
|
|
],
|
|
]
|
|
_ = try env.writePiSessionFile(
|
|
relativePath: "2026-04-09T10-00-00-000Z_test.jsonl",
|
|
contents: env.jsonl([supportedPiAssistant, unsupportedPiAssistant]))
|
|
|
|
let nativeOptions = CostUsageScanner.Options(
|
|
codexSessionsRoot: env.codexSessionsRoot,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
let piOptions = PiSessionCostScanner.Options(
|
|
piSessionsRoot: env.piSessionsRoot,
|
|
cacheRoot: env.cacheRoot,
|
|
refreshMinIntervalSeconds: 0)
|
|
|
|
let snapshot = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .claude,
|
|
now: day,
|
|
scannerOptions: nativeOptions,
|
|
piScannerOptions: piOptions)
|
|
|
|
let nativeCost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 100,
|
|
cacheReadInputTokens: 5,
|
|
cacheCreationInputTokens: 10,
|
|
outputTokens: 20) ?? 0
|
|
let piCost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 50,
|
|
cacheReadInputTokens: 4,
|
|
cacheCreationInputTokens: 6,
|
|
outputTokens: 10) ?? 0
|
|
|
|
#expect(snapshot.daily.count == 1)
|
|
#expect(snapshot.daily.first?.date == "2026-04-09")
|
|
#expect(snapshot.daily.first?.totalTokens == 205)
|
|
#expect(abs((snapshot.daily.first?.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001)
|
|
#expect(snapshot.daily.first?.modelBreakdowns == [
|
|
CostUsageDailyReport.ModelBreakdown(
|
|
modelName: "claude-sonnet-4-6",
|
|
costUSD: nativeCost + piCost,
|
|
totalTokens: 205),
|
|
])
|
|
}
|
|
|
|
@Test
|
|
func `fetcher prefers turn context model over token count fallback`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 10)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
let nativeTurnContext: [String: Any] = [
|
|
"type": "turn_context",
|
|
"timestamp": iso0,
|
|
"payload": [
|
|
"model": "openai/gpt-5.4",
|
|
],
|
|
]
|
|
let nativeTokenCount: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso1,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": "gpt-5",
|
|
"total_token_usage": [
|
|
"input_tokens": 100,
|
|
"cached_input_tokens": 20,
|
|
"output_tokens": 10,
|
|
],
|
|
],
|
|
],
|
|
]
|
|
_ = try env.writeCodexSessionFile(
|
|
day: day,
|
|
filename: "session.jsonl",
|
|
contents: env.jsonl([nativeTurnContext, nativeTokenCount]))
|
|
|
|
let nativeOptions = CostUsageScanner.Options(
|
|
codexSessionsRoot: env.codexSessionsRoot,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
let piOptions = PiSessionCostScanner.Options(
|
|
piSessionsRoot: env.piSessionsRoot,
|
|
cacheRoot: env.cacheRoot,
|
|
refreshMinIntervalSeconds: 0)
|
|
|
|
let snapshot = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
scannerOptions: nativeOptions,
|
|
piScannerOptions: piOptions)
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.4",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10) ?? 0
|
|
|
|
let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first)
|
|
#expect(breakdown.modelName == "gpt-5.4")
|
|
#expect(abs((breakdown.costUSD ?? 0) - cost) < 0.000001)
|
|
#expect(breakdown.totalTokens == 110)
|
|
}
|
|
|
|
@Test
|
|
func `app refresh bypasses scanner debounce without changing direct callers`() async throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 4, day: 11)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
let iso2 = env.isoString(for: day.addingTimeInterval(2))
|
|
let model = "openai/gpt-5.4"
|
|
|
|
let turnContext: [String: Any] = [
|
|
"type": "turn_context",
|
|
"timestamp": iso0,
|
|
"payload": ["model": model],
|
|
]
|
|
let firstTokenCount: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso1,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": model,
|
|
"total_token_usage": [
|
|
"input_tokens": 100,
|
|
"cached_input_tokens": 20,
|
|
"output_tokens": 10,
|
|
],
|
|
],
|
|
],
|
|
]
|
|
let fileURL = try env.writeCodexSessionFile(
|
|
day: day,
|
|
filename: "session.jsonl",
|
|
contents: env.jsonl([turnContext, firstTokenCount]))
|
|
|
|
let nativeOptions = CostUsageScanner.Options(
|
|
codexSessionsRoot: env.codexSessionsRoot,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
let piOptions = PiSessionCostScanner.Options(
|
|
piSessionsRoot: env.piSessionsRoot,
|
|
cacheRoot: env.cacheRoot)
|
|
|
|
let first = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
scannerOptions: nativeOptions,
|
|
piScannerOptions: piOptions)
|
|
#expect(first.daily.first?.totalTokens == 110)
|
|
|
|
let appendedTokenCount: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso2,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"model": model,
|
|
"total_token_usage": [
|
|
"input_tokens": 160,
|
|
"cached_input_tokens": 40,
|
|
"output_tokens": 16,
|
|
],
|
|
],
|
|
],
|
|
]
|
|
try env.jsonl([turnContext, firstTokenCount, appendedTokenCount])
|
|
.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
|
|
let debounced = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
scannerOptions: nativeOptions,
|
|
piScannerOptions: piOptions)
|
|
#expect(debounced.daily.first?.totalTokens == 110)
|
|
|
|
let refreshed = try await CostUsageFetcher.loadTokenSnapshot(
|
|
provider: .codex,
|
|
now: day,
|
|
bypassScannerDebounce: true,
|
|
scannerOptions: nativeOptions,
|
|
piScannerOptions: piOptions)
|
|
|
|
#expect(refreshed.daily.first?.totalTokens == 176)
|
|
}
|
|
|
|
private static func writeCodexSessionFile(
|
|
homeRoot: URL,
|
|
env: CostUsageTestEnvironment,
|
|
day: Date,
|
|
filename: String,
|
|
tokens: Int) throws
|
|
{
|
|
let comps = Calendar.current.dateComponents([.year, .month, .day], from: day)
|
|
let dir = homeRoot
|
|
.appendingPathComponent("sessions", isDirectory: true)
|
|
.appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true)
|
|
.appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true)
|
|
.appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true)
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
|
|
let model = "openai/gpt-5.4"
|
|
let url = dir.appendingPathComponent(filename, isDirectory: false)
|
|
try env.jsonl([
|
|
[
|
|
"type": "turn_context",
|
|
"timestamp": env.isoString(for: day),
|
|
"payload": ["model": model],
|
|
],
|
|
[
|
|
"type": "event_msg",
|
|
"timestamp": env.isoString(for: day.addingTimeInterval(1)),
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"last_token_usage": [
|
|
"input_tokens": tokens,
|
|
"cached_input_tokens": 0,
|
|
"output_tokens": 0,
|
|
],
|
|
"model": model,
|
|
],
|
|
],
|
|
],
|
|
]).write(to: url, atomically: true, encoding: .utf8)
|
|
}
|
|
}
|