import Foundation import Testing @testable import CodexBarCore struct PiSessionCostScannerTests { @Test func `pi scanner maps assistant usage to codex and claude reports`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let codexDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) let claudeDay = try env.makeLocalNoon(year: 2026, month: 4, day: 3) let codexEntry: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: codexDay), "message": [ "role": "assistant", "provider": "openai-codex", "model": "openai/gpt-5.4", "timestamp": Int(codexDay.timeIntervalSince1970 * 1000), "usage": [ "input": 120, "output": 30, "cacheRead": 10, "cacheWrite": 5, "totalTokens": 165, ], ], ] let claudeEntry: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: claudeDay), "message": [ "role": "assistant", "provider": "anthropic", "model": "anthropic.foo.claude-sonnet-4-6-v1:0", "timestamp": Int(claudeDay.timeIntervalSince1970 * 1000), "usage": [ "input": 80, "output": 20, "cacheRead": 4, "cacheWrite": 6, "totalTokens": 110, ], ], ] _ = try env.writePiSessionFile( relativePath: "nested/run-0/2026-04-02T10-00-00-000Z_test.jsonl", contents: env.jsonl([codexEntry, claudeEntry])) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0) let codexReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: codexDay, until: claudeDay, now: claudeDay, options: options) let expectedCodexCost = CostUsagePricing.codexCostUSD( model: "gpt-5.4", inputTokens: 135, cachedInputTokens: 10, outputTokens: 30) #expect(codexReport.data.count == 1) #expect(codexReport.data.first?.date == "2026-04-02") #expect(codexReport.data.first?.totalTokens == 165) #expect(abs((codexReport.data.first?.costUSD ?? 0) - (expectedCodexCost ?? 0)) < 0.000001) #expect(codexReport.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.4"]) let claudeReport = PiSessionCostScanner.loadDailyReport( provider: .claude, since: codexDay, until: claudeDay, now: claudeDay, options: options) let expectedClaudeCost = CostUsagePricing.claudeCostUSD( model: "claude-sonnet-4-6", inputTokens: 80, cacheReadInputTokens: 4, cacheCreationInputTokens: 6, outputTokens: 20) #expect(claudeReport.data.count == 1) #expect(claudeReport.data.first?.date == "2026-04-03") #expect(claudeReport.data.first?.totalTokens == 110) #expect(abs((claudeReport.data.first?.costUSD ?? 0) - (expectedClaudeCost ?? 0)) < 0.000001) #expect(claudeReport.data.first?.modelBreakdowns?.map(\.modelName) == ["claude-sonnet-4-6"]) } @Test func `pi codex cache reads are billed once and use the true context size`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": "openai/gpt-5.4", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 180_000, "cacheRead": 60000, "output": 0, "totalTokens": 240_000, ], ], ] _ = try env.writePiSessionFile( relativePath: "2026-04-04T10-00-00-000Z_cache-read.jsonl", contents: env.jsonl([assistant])) let report = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0)) let expectedCost = CostUsagePricing.codexCostUSD( model: "gpt-5.4", inputTokens: 240_000, cachedInputTokens: 60000, outputTokens: 0) #expect(report.data.count == 1) #expect(report.data.first?.inputTokens == 180_000) #expect(report.data.first?.cacheReadTokens == 60000) #expect(report.data.first?.totalTokens == 240_000) #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } @Test func `pi scanner keeps ambiguous claude errors priced`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) let claudeEntry: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "anthropic", "model": "claude-fable-5", "stopReason": "error", "errorMessage": "An unknown error occurred", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 100, "output": 0, "cacheRead": 20, "cacheWrite": 10, "totalTokens": 130, ], ], ] _ = try env.writePiSessionFile( relativePath: "2026-06-09T10-00-00-000Z_refusal.jsonl", contents: env.jsonl([claudeEntry])) let report = PiSessionCostScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0)) #expect(report.data.count == 1) #expect(report.data.first?.totalTokens == 130) let expectedCost = CostUsagePricing.claudeCostUSD( model: "claude-fable-5", inputTokens: 100, cacheReadInputTokens: 20, cacheCreationInputTokens: 10, outputTokens: 0) #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } @Test func `pi scanner uses model change fallback and assistant timestamp day`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let sessionStart = try env.makeLocalNoon(year: 2026, month: 4, day: 1) let assistantDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) let modelChange: [String: Any] = [ "type": "model_change", "timestamp": env.isoString(for: sessionStart), "provider": "openai-codex", "modelId": "openai/gpt-5.3-codex", ] let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: sessionStart), "message": [ "role": "assistant", "timestamp": Int(assistantDay.timeIntervalSince1970 * 1000), "usage": [ "input": 20, "cacheRead": 2, "output": 20, "totalTokens": 42, ], ], ] _ = try env.writePiSessionFile( relativePath: "2026-04-01T09-00-00-000Z_test.jsonl", contents: env.jsonl([modelChange, assistant])) let report = PiSessionCostScanner.loadDailyReport( provider: .codex, since: sessionStart, until: assistantDay, now: assistantDay, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0)) let expectedCost = CostUsagePricing.codexCostUSD( model: "gpt-5.3-codex", inputTokens: 22, cachedInputTokens: 2, outputTokens: 20) #expect(report.data.count == 1) #expect(report.data.first?.date == "2026-04-02") #expect(report.data.first?.totalTokens == 42) #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.3-codex"]) } @Test func `pi scanner refreshes appended file without duplicating existing usage`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) let firstTimestamp = Int(day.timeIntervalSince1970 * 1000) let secondTimestamp = Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000) let firstAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": "openai/gpt-5.4", "timestamp": firstTimestamp, "usage": [ "input": 10, "output": 5, "totalTokens": 15, ], ], ] let secondAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": "gpt-5.4", "timestamp": secondTimestamp, "usage": [ "input": 20, "output": 10, "totalTokens": 30, ], ], ] let url = try env.writePiSessionFile( relativePath: "2026-04-04T10-00-00-000Z_test.jsonl", contents: env.jsonl([firstAssistant])) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0) let firstReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) let firstExpectedCost = CostUsagePricing.codexCostUSD( model: "gpt-5.4", inputTokens: 10, cachedInputTokens: 0, outputTokens: 5) #expect(firstReport.data.count == 1) #expect(firstReport.data.first?.totalTokens == 15) #expect(abs((firstReport.data.first?.costUSD ?? 0) - (firstExpectedCost ?? 0)) < 0.000001) try env.jsonl([firstAssistant, secondAssistant]).write(to: url, atomically: true, encoding: .utf8) let secondReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) let secondExpectedCost = (firstExpectedCost ?? 0) + (CostUsagePricing.codexCostUSD( model: "gpt-5.4", inputTokens: 20, cachedInputTokens: 0, outputTokens: 10) ?? 0) #expect(secondReport.data.count == 1) #expect(secondReport.data.first?.totalTokens == 45) #expect(abs((secondReport.data.first?.costUSD ?? 0) - secondExpectedCost) < 0.000001) } @Test func `pi scanner ignores explicit unsupported provider even with fallback context`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 4, day: 5) let modelChange: [String: Any] = [ "type": "model_change", "timestamp": env.isoString(for: day), "provider": "openai-codex", "modelId": "gpt-5.4", ] let unsupportedAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openrouter", "model": "gpt-5.4", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 99, "output": 1, "totalTokens": 100, ], ], ] let fallbackAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day.addingTimeInterval(60)), "message": [ "role": "assistant", "timestamp": Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000), "usage": [ "input": 10, "output": 5, "totalTokens": 15, ], ], ] _ = try env.writePiSessionFile( relativePath: "2026-04-05T10-00-00-000Z_test.jsonl", contents: env.jsonl([modelChange, unsupportedAssistant, fallbackAssistant])) let report = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0)) #expect(report.data.count == 1) #expect(report.data.first?.totalTokens == 15) #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.4"]) } @Test func `pi scanner force rescan bypasses stale same size metadata cache`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 4, day: 6) let assistantOne: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": "gpt-5.4", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 10, "output": 5, "totalTokens": 15, ], ], ] let assistantTwo: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": "gpt-5.4", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 20, "output": 5, "totalTokens": 25, ], ], ] let firstContents = try env.jsonl([assistantOne]) let secondContents = try env.jsonl([assistantTwo]) #expect(firstContents.utf8.count == secondContents.utf8.count) let url = try env.writePiSessionFile( relativePath: "2026-04-06T10-00-00-000Z_test.jsonl", contents: firstContents) let originalModifiedAt = try #require( FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) let cachedOptions = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0) let firstReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: cachedOptions) #expect(firstReport.data.first?.totalTokens == 15) try secondContents.write(to: url, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.modificationDate: originalModifiedAt], ofItemAtPath: url.path) let staleReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: cachedOptions) #expect(staleReport.data.first?.totalTokens == 15) let refreshedReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0, forceRescan: true)) #expect(refreshedReport.data.first?.totalTokens == 25) } @Test func `pi scanner derives cost when explicit cost is absent`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 4, day: 7) let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "anthropic", "model": "claude-sonnet-4-6", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 70, "cacheRead": 4, "cacheWrite": 6, "output": 19, "totalTokens": 99, ], ], ] _ = try env.writePiSessionFile( relativePath: "2026-04-07T10-00-00-000Z_test.jsonl", contents: env.jsonl([assistant])) let report = PiSessionCostScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0)) let expectedCost = CostUsagePricing.claudeCostUSD( model: "claude-sonnet-4-6", inputTokens: 70, cacheReadInputTokens: 4, cacheCreationInputTokens: 6, outputTokens: 19) #expect(report.data.count == 1) #expect(report.data.first?.totalTokens == 99) #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } @Test func `pi scanner preserves per-message threshold pricing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) let model = "claude-sonnet-4-5" let firstAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "anthropic", "model": model, "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 150_000, "output": 0, "totalTokens": 150_000, ], ], ] let secondAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day.addingTimeInterval(1)), "message": [ "role": "assistant", "provider": "anthropic", "model": model, "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), "usage": [ "input": 150_000, "output": 0, "totalTokens": 150_000, ], ], ] _ = try env.writePiSessionFile( relativePath: "2026-05-09T10-00-00-000Z_threshold.jsonl", contents: env.jsonl([firstAssistant, secondAssistant])) let report = PiSessionCostScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 0)) let expectedRequestCost = CostUsagePricing.claudeCostUSD( model: model, inputTokens: 150_000, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, outputTokens: 0, modelsDevCacheRoot: env.cacheRoot) ?? 0 let aggregateCost = CostUsagePricing.claudeCostUSD( model: model, inputTokens: 300_000, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, outputTokens: 0, modelsDevCacheRoot: env.cacheRoot) ?? 0 let expectedCost = expectedRequestCost * 2 #expect(report.data.count == 1) #expect(report.data.first?.totalTokens == 300_000) #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001) } @Test func `pi scanner ignores v3 cache with stale codex cached input pricing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let model = "gpt-5.4" let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": model, "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 180_000, "cacheRead": 60000, "output": 0, "totalTokens": 240_000, ], ], ] let fileURL = try env.writePiSessionFile( relativePath: "2026-05-10T10-00-00-000Z_cache-read.jsonl", contents: env.jsonl([assistant])) let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) let mtime = try #require(attrs[.modificationDate] as? Date) let size = try #require((attrs[.size] as? NSNumber)?.int64Value) let expectedCost = CostUsagePricing.codexCostUSD( model: model, inputTokens: 240_000, cachedInputTokens: 60000, outputTokens: 0) ?? 0 let staleCost = CostUsagePricing.codexCostUSD( model: model, inputTokens: 180_000, cachedInputTokens: 60000, outputTokens: 0, modelsDevCacheRoot: env.cacheRoot) ?? 0 let stalePacked = PiPackedUsage( inputTokens: 180_000, cacheReadTokens: 60000, outputTokens: 0, totalTokens: 240_000, costNanos: Int64((staleCost * 1_000_000_000).rounded()), costSampleCount: 1, usageSampleCount: 1) let dayKey = "2026-05-10" let contributions = [ UsageProvider.codex.rawValue: [ dayKey: [ model: stalePacked, ], ], ] let oldFileUsage = PiSessionFileUsage( mtimeUnixMs: Int64(mtime.timeIntervalSince1970 * 1000), size: size, parsedBytes: size, lastModelContext: nil, contributions: contributions) var oldCache = PiSessionCostCache(version: 3) oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) oldCache.scanSinceKey = dayKey oldCache.scanUntilKey = dayKey oldCache.daysByProvider = contributions oldCache.files = [fileURL.path: oldFileUsage] let oldCacheURL = env.cacheRoot .appendingPathComponent("cost-usage", isDirectory: true) .appendingPathComponent("pi-sessions-v3.json", isDirectory: false) try FileManager.default.createDirectory( at: oldCacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) try JSONEncoder().encode(oldCache).write(to: oldCacheURL) let report = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600)) #expect(report.data.count == 1) #expect(report.data.first?.totalTokens == 240_000) #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) #expect(abs((report.data.first?.costUSD ?? 0) - staleCost) > 0.000001) let newCacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] #expect(newCacheURL.lastPathComponent == "pi-sessions-v5.json") #expect(newCache.version == 5) #expect(rebuilt?.usageSampleCount == 1) #expect(rebuilt?.costSampleCount == 1) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) } @Test func `pi scanner ignores v4 cache with stale gpt56 cache write pricing`() throws { // v4 stored complete costNanos before cache-write rates existed; v5 must reprice. let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) let model = "gpt-5.6-sol" let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": model, "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": [ "input": 70, "cacheRead": 10, "cacheWrite": 20, "output": 5, "totalTokens": 105, ], ], ] let fileURL = try env.writePiSessionFile( relativePath: "2026-07-10T10-00-00-000Z_cache-write.jsonl", contents: env.jsonl([assistant])) let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) let mtime = try #require(attrs[.modificationDate] as? Date) let size = try #require((attrs[.size] as? NSNumber)?.int64Value) let expectedCost = CostUsagePricing.codexCostUSD( model: model, inputTokens: 100, cachedInputTokens: 10, outputTokens: 5, cacheWriteInputTokens: 20, modelsDevCacheRoot: env.cacheRoot) ?? 0 // Stale: writes folded into uncached input at 1× (pre-v5 behavior). let staleCost = CostUsagePricing.codexCostUSD( model: model, inputTokens: 100, cachedInputTokens: 10, outputTokens: 5, modelsDevCacheRoot: env.cacheRoot) ?? 0 #expect(abs(expectedCost - staleCost) > 0.000001) let stalePacked = PiPackedUsage( inputTokens: 70, cacheReadTokens: 10, cacheWriteTokens: 20, outputTokens: 5, totalTokens: 105, costNanos: Int64((staleCost * 1_000_000_000).rounded()), costSampleCount: 1, usageSampleCount: 1) let dayKey = "2026-07-10" let contributions = [ UsageProvider.codex.rawValue: [ dayKey: [ model: stalePacked, ], ], ] let oldFileUsage = PiSessionFileUsage( mtimeUnixMs: Int64(mtime.timeIntervalSince1970 * 1000), size: size, parsedBytes: size, lastModelContext: nil, contributions: contributions) var oldCache = PiSessionCostCache(version: 4) oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) oldCache.scanSinceKey = dayKey oldCache.scanUntilKey = dayKey oldCache.daysByProvider = contributions oldCache.files = [fileURL.path: oldFileUsage] let oldCacheURL = env.cacheRoot .appendingPathComponent("cost-usage", isDirectory: true) .appendingPathComponent("pi-sessions-v4.json", isDirectory: false) try FileManager.default.createDirectory( at: oldCacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) try JSONEncoder().encode(oldCache).write(to: oldCacheURL) let report = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600)) #expect(report.data.count == 1) #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) #expect(abs((report.data.first?.costUSD ?? 0) - staleCost) > 0.000001) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] #expect(newCache.version == 5) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) } } extension PiSessionCostScannerTests { @Test func `pi scanner reprices unchanged files when catalog rates change`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) let model = "gpt-5.6-sol" func assistant(at timestamp: Date) -> [String: Any] { [ "type": "message", "timestamp": env.isoString(for: timestamp), "message": [ "role": "assistant", "provider": "openai-codex", "model": model, "timestamp": Int(timestamp.timeIntervalSince1970 * 1000), "usage": [ "input": 150_000, "output": 0, "totalTokens": 150_000, ], ], ] } _ = try env.writePiSessionFile( relativePath: "2026-07-10T10-00-00-000Z_catalog-change.jsonl", contents: env.jsonl([ assistant(at: day.addingTimeInterval(-1)), assistant(at: day), ])) let firstCatalog = try Self.modelsDevCatalog(inputCostPerMillion: 4) #expect(ModelsDevCache.save(catalog: firstCatalog, fetchedAt: day, cacheRoot: env.cacheRoot)) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600) let firstReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let firstPricingKey = try #require(firstCache.pricingKey) #expect(firstReport.data.first?.totalTokens == 300_000) #expect(abs((firstReport.data.first?.costUSD ?? 0) - 1.2) < 0.0000001) let secondCatalog = try Self.modelsDevCatalog(inputCostPerMillion: 8) #expect(ModelsDevCache.save( catalog: secondCatalog, fetchedAt: day.addingTimeInterval(1), cacheRoot: env.cacheRoot)) #expect(PiSessionCostScanner.loadCachedDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(1), cacheRoot: env.cacheRoot) == nil) let secondReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(2), options: options) let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(secondCache.pricingKey != firstPricingKey) // Each 150K message stays below the 272K threshold. The 300K daily aggregate must be the // sum of two short-context costs, proving the pricing change triggered a full-file reparse. #expect(secondReport.data.first?.totalTokens == 300_000) #expect(abs((secondReport.data.first?.costUSD ?? 0) - 2.4) < 0.0000001) } @Test func `pi scanner reprices unchanged claude files when anthropic rates change`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) let model = "claude-fable-5" func assistant(at timestamp: Date) -> [String: Any] { [ "type": "message", "timestamp": env.isoString(for: timestamp), "message": [ "role": "assistant", "provider": "anthropic", "model": model, "timestamp": Int(timestamp.timeIntervalSince1970 * 1000), "usage": [ "input": 150_000, "output": 0, "totalTokens": 150_000, ], ], ] } _ = try env.writePiSessionFile( relativePath: "2026-07-10T10-00-00-000Z_anthropic-catalog-change.jsonl", contents: env.jsonl([ assistant(at: day.addingTimeInterval(-1)), assistant(at: day), ])) let firstCatalog = try Self.anthropicModelsDevCatalog(inputCostPerMillion: 4) #expect(ModelsDevCache.save(catalog: firstCatalog, fetchedAt: day, cacheRoot: env.cacheRoot)) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600) let firstReport = PiSessionCostScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let firstPricingKey = try #require(firstCache.pricingKey) #expect(firstReport.data.first?.totalTokens == 300_000) #expect(abs((firstReport.data.first?.costUSD ?? 0) - 1.2) < 0.0000001) let secondCatalog = try Self.anthropicModelsDevCatalog(inputCostPerMillion: 8) #expect(ModelsDevCache.save( catalog: secondCatalog, fetchedAt: day.addingTimeInterval(1), cacheRoot: env.cacheRoot)) #expect(PiSessionCostScanner.loadCachedDailyReport( provider: .claude, since: day, until: day, now: day.addingTimeInterval(1), cacheRoot: env.cacheRoot) == nil) let secondReport = PiSessionCostScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day.addingTimeInterval(2), options: options) let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(secondCache.pricingKey != firstPricingKey) #expect(secondReport.data.first?.totalTokens == 300_000) #expect(abs((secondReport.data.first?.costUSD ?? 0) - 2.4) < 0.0000001) } @Test func `pi pricing key ignores catalog fetch time when rates are unchanged`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) let catalog = try Self.modelsDevCatalog(inputCostPerMillion: 4) #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: day, cacheRoot: env.cacheRoot)) let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": "gpt-5.6-sol", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": ["input": 100, "output": 0, "totalTokens": 100], ], ] _ = try env.writePiSessionFile( relativePath: "2026-07-10T10-00-00-000Z_catalog-fetch-time.jsonl", contents: env.jsonl([assistant])) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600) _ = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(ModelsDevCache.save( catalog: catalog, fetchedAt: day.addingTimeInterval(1), cacheRoot: env.cacheRoot)) _ = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(2), options: options) let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(secondCache.pricingKey == firstCache.pricingKey) #expect(secondCache.lastScanUnixMs == firstCache.lastScanUnixMs) } @Test func `pi pricing key ignores unrelated providers and non pricing context metadata`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) let firstCatalog = try Self.modelsDevCatalog(""" { "openai": { "id": "openai", "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", "limit": { "context": 1000000 }, "cost": { "input": 4, "output": 30 } } } }, "google": { "id": "google", "models": { "gemini-test": { "id": "gemini-test", "cost": { "input": 1, "output": 2 } } } } } """) #expect(ModelsDevCache.save(catalog: firstCatalog, fetchedAt: day, cacheRoot: env.cacheRoot)) let assistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: day), "message": [ "role": "assistant", "provider": "openai-codex", "model": "gpt-5.6-sol", "timestamp": Int(day.timeIntervalSince1970 * 1000), "usage": ["input": 100, "output": 0, "totalTokens": 100], ], ] _ = try env.writePiSessionFile( relativePath: "2026-07-10T10-00-00-000Z_catalog-metadata.jsonl", contents: env.jsonl([assistant])) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600) _ = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day, options: options) let firstCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let metadataOnlyChange = try Self.modelsDevCatalog(""" { "openai": { "id": "openai", "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", "limit": { "context": 2000000 }, "cost": { "input": 4, "output": 30 } } } }, "google": { "id": "google", "models": { "gemini-test": { "id": "gemini-test", "cost": { "input": 99, "output": 199 } } } } } """) #expect(ModelsDevCache.save( catalog: metadataOnlyChange, fetchedAt: day.addingTimeInterval(1), cacheRoot: env.cacheRoot)) _ = PiSessionCostScanner.loadDailyReport( provider: .codex, since: day, until: day, now: day.addingTimeInterval(2), options: options) let secondCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(secondCache.pricingKey == firstCache.pricingKey) #expect(secondCache.lastScanUnixMs == firstCache.lastScanUnixMs) } @Test func `pi scanner reparses unchanged cached file when scan window expands`() 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) let oldAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: oldDay), "message": [ "role": "assistant", "provider": "openai-codex", "model": "gpt-5.4", "timestamp": Int(oldDay.timeIntervalSince1970 * 1000), "usage": [ "input": 10, "output": 5, "totalTokens": 15, ], ], ] let newAssistant: [String: Any] = [ "type": "message", "timestamp": env.isoString(for: newDay), "message": [ "role": "assistant", "provider": "openai-codex", "model": "gpt-5.4", "timestamp": Int(newDay.timeIntervalSince1970 * 1000), "usage": [ "input": 20, "output": 10, "totalTokens": 30, ], ], ] _ = try env.writePiSessionFile( relativePath: "2026-04-08T10-00-00-000Z_test.jsonl", contents: env.jsonl([oldAssistant, newAssistant])) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600) let narrowReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: newDay, until: newDay, now: newDay, options: options) #expect(narrowReport.data.map(\.date) == ["2026-04-08"]) #expect(narrowReport.data.first?.totalTokens == 30) let expandedReport = PiSessionCostScanner.loadDailyReport( provider: .codex, since: oldDay, until: newDay, now: newDay.addingTimeInterval(1), options: options) #expect(expandedReport.data.map(\.date) == ["2026-04-02", "2026-04-08"]) #expect(expandedReport.summary?.totalTokens == 45) } private static func modelsDevCatalog(inputCostPerMillion: Double) throws -> ModelsDevCatalog { let json = """ { "openai": { "id": "openai", "models": { "gpt-5.6-sol": { "id": "gpt-5.6-sol", "cost": { "input": \(inputCostPerMillion), "output": 30, "cache_read": 0.5, "cache_write": 6.25 } } } } } """ return try self.modelsDevCatalog(json) } private static func anthropicModelsDevCatalog(inputCostPerMillion: Double) throws -> ModelsDevCatalog { let json = """ { "anthropic": { "id": "anthropic", "models": { "claude-fable-5": { "id": "claude-fable-5", "cost": { "input": \(inputCostPerMillion), "output": 15, "cache_read": 0.3, "cache_write": 3.75 } } } } } """ return try self.modelsDevCatalog(json) } private static func modelsDevCatalog(_ json: String) throws -> ModelsDevCatalog { try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } }