import Foundation import Testing @testable import CodexBarCore struct CostUsageScannerClaudeFableTests { @Test func `claude fable 5 issue row gets priced`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) let fileURL = try env.writeClaudeProjectFile( relativePath: "project-a/fable-5.jsonl", contents: env.jsonl([ [ "message": [ "model": "claude-fable-5", "id": "msg_fable_5", "type": "message", "role": "assistant", "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 20, "output_tokens": 5, ], ], "requestId": "req_fable_5", "type": "assistant", "timestamp": "2026-06-09T12:00:00.000Z", "sessionId": "session_fable_5", ], ])) let parsed = CostUsageScanner.parseClaudeFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day), providerFilter: .all) #expect(parsed.rows.count == 1) #expect(parsed.rows[0].model == "claude-fable-5") let expected = 0.001395 #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) } @Test func `claude transcript refusal remains priced without billing provenance`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) let fileURL = try env.writeClaudeProjectFile( relativePath: "project-a/fable-5-refusal.jsonl", contents: env.jsonl([ [ "message": [ "model": "claude-fable-5", "id": "msg_fable_5_refusal", "type": "message", "role": "assistant", "stop_reason": "refusal", "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 20, "output_tokens": 0, ], ], "requestId": "req_fable_5_refusal", "type": "assistant", "timestamp": "2026-06-09T12:00:00.000Z", "sessionId": "session_fable_5_refusal", ], ])) let parsed = CostUsageScanner.parseClaudeFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day), providerFilter: .all) #expect(parsed.rows.count == 1) #expect(parsed.rows[0].input == 100) #expect(parsed.rows[0].cacheCreate == 10) #expect(parsed.rows[0].cacheRead == 20) #expect(parsed.rows[0].output == 0) let expected = 0.001145 #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) #expect(parsed.rows[0].costPriced == true) } @Test func `claude fable 5 prices one hour cache creation tokens`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) let fileURL = try env.writeClaudeProjectFile( relativePath: "project-a/fable-5-cache-ttl.jsonl", contents: env.jsonl([ [ "message": [ "model": "claude-fable-5", "id": "msg_fable_5_cache_ttl", "type": "message", "role": "assistant", "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 30, "cache_creation": [ "ephemeral_5m_input_tokens": 10, "ephemeral_1h_input_tokens": 20, ], "cache_read_input_tokens": 20, "output_tokens": 5, ], ], "requestId": "req_fable_5_cache_ttl", "type": "assistant", "timestamp": "2026-06-09T12:00:00.000Z", "sessionId": "session_fable_5_cache_ttl", ], ])) let parsed = CostUsageScanner.parseClaudeFile( fileURL: fileURL, range: CostUsageScanner.CostUsageDayRange(since: day, until: day), providerFilter: .all) #expect(parsed.rows.count == 1) #expect(parsed.rows[0].cacheCreate == 30) #expect(parsed.rows[0].cacheCreate1h == 20) let expected = 0.001795 #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) } @Test func `claude cached rows preserve one hour writes for deferred pricing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) _ = try env.writeClaudeProjectFile( relativePath: "project-a/custom-cache-ttl.jsonl", contents: env.jsonl([ [ "message": [ "model": "claude-custom-cache-model", "id": "msg_custom_cache_ttl", "type": "message", "role": "assistant", "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 30, "cache_creation": [ "ephemeral_5m_input_tokens": 10, "ephemeral_1h_input_tokens": 20, ], "cache_read_input_tokens": 20, "output_tokens": 5, ], ], "requestId": "req_custom_cache_ttl", "type": "assistant", "timestamp": "2026-06-09T12:00:00.000Z", "sessionId": "session_custom_cache_ttl", ], ])) var options = CostUsageScanner.Options( claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let unpriced = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(unpriced.summary?.totalCostUSD == nil) let cached = CostUsageCacheIO.load(provider: .claude, cacheRoot: env.cacheRoot) #expect(cached.days["2026-06-09"]?["claude-custom-cache-model"]?[safe: 7] == 20) try ModelsDevCache.save( catalog: Self.anthropicModelsDevCatalog(model: "claude-custom-cache-model"), fetchedAt: day, cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 3600 let repriced = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day.addingTimeInterval(1), options: options) let expected = 0.001795 #expect(abs((repriced.summary?.totalCostUSD ?? 0) - expected) < 0.000000001) } @Test func `claude deferred pricing preserves request long context boundaries`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) _ = try env.writeClaudeProjectFile( relativePath: "project-a/custom-threshold.jsonl", contents: env.jsonl([ Self.claudeUsageEvent( model: "claude-custom-threshold-model", messageID: "msg_custom_threshold_1", requestID: "req_custom_threshold_1", inputTokens: 150_000), Self.claudeUsageEvent( model: "claude-custom-threshold-model", messageID: "msg_custom_threshold_2", requestID: "req_custom_threshold_2", inputTokens: 150_000), ])) var options = CostUsageScanner.Options( claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let unpriced = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(unpriced.summary?.totalCostUSD == nil) try ModelsDevCache.save( catalog: Self.anthropicThresholdModelsDevCatalog(model: "claude-custom-threshold-model"), fetchedAt: day, cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 3600 let repriced = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day.addingTimeInterval(1), options: options) #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 3) < 0.000000001) } @Test func `claude cached rows reprice after models dev catalog changes`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) let model = "claude-custom-repricing-model" _ = try env.writeClaudeProjectFile( relativePath: "project-a/custom-repricing.jsonl", contents: env.jsonl([ Self.claudeUsageEvent( model: model, messageID: "msg_custom_repricing", requestID: "req_custom_repricing", inputTokens: 240_000), ])) try ModelsDevCache.save( catalog: Self.anthropicThresholdModelsDevCatalog(model: model), fetchedAt: day, cacheRoot: env.cacheRoot) var options = CostUsageScanner.Options( claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let premium = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(abs((premium.summary?.totalCostUSD ?? 0) - 4.8) < 0.000000001) try ModelsDevCache.save( catalog: Self.anthropicModelsDevCatalog(model: model), fetchedAt: day.addingTimeInterval(1), cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 3600 let repriced = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day.addingTimeInterval(2), options: options) #expect(abs((repriced.summary?.totalCostUSD ?? 0) - 2.4) < 0.000000001) } @Test func `claude cached historical rows keep original tariff`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 3, day: 12) _ = try env.writeClaudeProjectFile( relativePath: "project-a/sonnet-46-historical.jsonl", contents: env.jsonl([ [ "message": [ "model": "claude-sonnet-4-6", "id": "msg_sonnet_46_historical", "type": "message", "role": "assistant", "usage": [ "input_tokens": 240_000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, ], ], "requestId": "req_sonnet_46_historical", "type": "assistant", "timestamp": "2026-03-12T12:00:00.000Z", "sessionId": "session_sonnet_46_historical", ], ])) var options = CostUsageScanner.Options( claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let initial = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(abs((initial.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) try ModelsDevCache.save( catalog: Self.anthropicSonnet46StandardCatalog(), fetchedAt: day, cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 3600 let cached = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day.addingTimeInterval(1), options: options) #expect(abs((cached.summary?.totalCostUSD ?? 0) - 1.44) < 0.000000001) } private static func anthropicModelsDevCatalog(model: String) throws -> ModelsDevCatalog { let json = """ { "anthropic": { "id": "anthropic", "models": { "\(model)": { "id": "\(model)", "cost": { "input": 10, "output": 50, "cache_read": 1, "cache_write": 12.5 } } } } } """ return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } private static func anthropicThresholdModelsDevCatalog(model: String) throws -> ModelsDevCatalog { let json = """ { "anthropic": { "id": "anthropic", "models": { "\(model)": { "id": "\(model)", "cost": { "input": 10, "output": 50, "cache_read": 1, "cache_write": 12.5, "context_over_200k": { "input": 20, "output": 75, "cache_read": 2, "cache_write": 25 } } } } } } """ return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } private static func anthropicSonnet46StandardCatalog() throws -> ModelsDevCatalog { let json = """ { "anthropic": { "id": "anthropic", "models": { "claude-sonnet-4-6": { "id": "claude-sonnet-4-6", "cost": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 } } } } } """ return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) } private static func claudeUsageEvent( model: String, messageID: String, requestID: String, inputTokens: Int) -> [String: Any] { [ "message": [ "model": model, "id": messageID, "type": "message", "role": "assistant", "usage": [ "input_tokens": inputTokens, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, ], ], "requestId": requestID, "type": "assistant", "timestamp": "2026-06-09T12:00:00.000Z", "sessionId": "session_\(requestID)", ] } }