458 lines
16 KiB
Swift
458 lines
16 KiB
Swift
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)",
|
|
]
|
|
}
|
|
}
|