1079 lines
41 KiB
Swift
1079 lines
41 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct CostUsageScannerTests {
|
|
@Test
|
|
func `codex session metadata skips an oversized line without retaining it`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let fileURL = env.root.appendingPathComponent("oversized-session-meta.jsonl")
|
|
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
|
|
let handle = try FileHandle(forWritingTo: fileURL)
|
|
defer { try? handle.close() }
|
|
|
|
let oversizedPrefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"too-large\",\"padding\":\""
|
|
try handle.write(contentsOf: Data(oversizedPrefix.utf8))
|
|
let chunk = Data(repeating: 0x78, count: 64 * 1024)
|
|
for _ in 0..<128 {
|
|
try handle.write(contentsOf: chunk)
|
|
}
|
|
let expectedLine = #"{"type":"session_meta","payload":{"id":"expected-session"}}"#
|
|
try handle.write(contentsOf: Data((#""}}"# + "\n" + expectedLine).utf8))
|
|
try handle.close()
|
|
|
|
let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL)
|
|
#expect(sessionID == "expected-session")
|
|
}
|
|
|
|
@Test
|
|
func `codex session metadata accepts a line exactly at the byte limit`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let prefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"limit-session\",\"padding\":\""
|
|
let suffix = "\"}}"
|
|
let paddingCount = CostUsageScanner.codexSessionMetadataMaxLineBytes
|
|
- prefix.utf8.count
|
|
- suffix.utf8.count
|
|
var line = Data(prefix.utf8)
|
|
line.append(Data(repeating: 0x78, count: paddingCount))
|
|
line.append(contentsOf: suffix.utf8)
|
|
#expect(line.count == CostUsageScanner.codexSessionMetadataMaxLineBytes)
|
|
|
|
let fileURL = env.root.appendingPathComponent("max-size-session-meta.jsonl")
|
|
try line.write(to: fileURL)
|
|
#expect(try (JSONSerialization.jsonObject(with: line)) is [String: Any])
|
|
|
|
let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL)
|
|
#expect(sessionID == "limit-session")
|
|
}
|
|
|
|
@Test
|
|
func `codex file metadata detects append truncation and replacement`() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-codex-metadata-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let fileURL = root.appendingPathComponent("session.jsonl")
|
|
try Data("abc".utf8).write(to: fileURL)
|
|
|
|
let initial = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
|
|
#expect(initial.size == 3)
|
|
#expect(initial.fileId != nil)
|
|
let linkURL = root.appendingPathComponent("linked-session.jsonl")
|
|
try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: fileURL)
|
|
let linked = CostUsageScanner.codexFileMetadata(fileURL: linkURL)
|
|
#expect(linked.size == initial.size)
|
|
#expect(linked.fileId == initial.fileId)
|
|
|
|
let handle = try FileHandle(forWritingTo: fileURL)
|
|
try handle.seekToEnd()
|
|
try handle.write(contentsOf: Data("def".utf8))
|
|
try handle.close()
|
|
let appended = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
|
|
#expect(appended.size == 6)
|
|
#expect(appended.fileId == initial.fileId)
|
|
|
|
let truncateHandle = try FileHandle(forWritingTo: fileURL)
|
|
try truncateHandle.truncate(atOffset: 2)
|
|
try truncateHandle.close()
|
|
let truncated = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
|
|
#expect(truncated.size == 2)
|
|
#expect(truncated.fileId == initial.fileId)
|
|
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
try Data("replacement".utf8).write(to: fileURL)
|
|
let replaced = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
|
|
#expect(replaced.size == 11)
|
|
#expect(replaced.fileId != initial.fileId)
|
|
}
|
|
|
|
@Test
|
|
func `vertex daily report filters claude logs`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
let iso0 = env.isoString(for: day)
|
|
|
|
let vertexEntry: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"metadata": [
|
|
"provider": "vertexai",
|
|
"projectId": "vertex-project",
|
|
"location": "us-central1",
|
|
],
|
|
"message": [
|
|
"model": "claude-sonnet-4-20250514",
|
|
"usage": [
|
|
"input_tokens": 10,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 5,
|
|
],
|
|
],
|
|
]
|
|
let claudeEntry: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"metadata": [
|
|
"provider": "anthropic",
|
|
],
|
|
"message": [
|
|
"model": "claude-sonnet-4-20250514",
|
|
"usage": [
|
|
"input_tokens": 200,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 100,
|
|
],
|
|
],
|
|
]
|
|
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session-a.jsonl",
|
|
contents: env.jsonl([vertexEntry, claudeEntry]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
let report = CostUsageScanner.loadDailyReport(
|
|
provider: .vertexai,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
|
|
#expect(report.data.count == 1)
|
|
#expect(report.data[0].inputTokens == 10)
|
|
#expect(report.data[0].outputTokens == 5)
|
|
#expect(report.data[0].totalTokens == 15)
|
|
}
|
|
|
|
@Test
|
|
func `vertex daily report detects by vrtx id prefix`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
// Vertex AI entries have "_vrtx_" in message.id and requestId
|
|
let vertexEntry: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"requestId": "req_vrtx_011CWjK86SWeFuXqZKUtgB1H",
|
|
"message": [
|
|
"id": "msg_vrtx_0154LUXjFVzQGUca3yK2RUeo",
|
|
"model": "claude-opus-4-5-20251101",
|
|
"usage": [
|
|
"input_tokens": 100,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 50,
|
|
],
|
|
],
|
|
]
|
|
// Anthropic API entries have regular IDs without "_vrtx_"
|
|
let claudeEntry: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso1,
|
|
"requestId": "req_011CW7BFSFkbK9qJrV8kiptH",
|
|
"message": [
|
|
"id": "msg_0152zX6DsQYcwH1qiXi4B3y2",
|
|
"model": "claude-opus-4-5-20251101",
|
|
"usage": [
|
|
"input_tokens": 200,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 100,
|
|
],
|
|
],
|
|
]
|
|
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session-a.jsonl",
|
|
contents: env.jsonl([vertexEntry, claudeEntry]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
// Vertex AI report should only include entries with _vrtx_ prefix
|
|
let vertexReport = CostUsageScanner.loadDailyReport(
|
|
provider: .vertexai,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
|
|
#expect(vertexReport.data.count == 1)
|
|
#expect(vertexReport.data[0].inputTokens == 100)
|
|
#expect(vertexReport.data[0].outputTokens == 50)
|
|
#expect(vertexReport.data[0].totalTokens == 150)
|
|
|
|
// Claude report with excludeVertexAI should only include non-vrtx entries
|
|
var claudeOptions = options
|
|
claudeOptions.claudeLogProviderFilter = .excludeVertexAI
|
|
let claudeReport = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: claudeOptions)
|
|
|
|
#expect(claudeReport.data.count == 1)
|
|
#expect(claudeReport.data[0].inputTokens == 200)
|
|
#expect(claudeReport.data[0].outputTokens == 100)
|
|
#expect(claudeReport.data[0].totalTokens == 300)
|
|
}
|
|
|
|
@Test
|
|
func `claude report preserves per-request threshold pricing`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9)
|
|
let first = env.isoString(for: day)
|
|
let second = env.isoString(for: day.addingTimeInterval(1))
|
|
let model = "claude-sonnet-4-5"
|
|
let firstEntry: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": first,
|
|
"requestId": "req_one",
|
|
"message": [
|
|
"id": "msg_one",
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 150_000,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
]
|
|
let secondEntry: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": second,
|
|
"requestId": "req_two",
|
|
"message": [
|
|
"id": "msg_two",
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 150_000,
|
|
"output_tokens": 0,
|
|
],
|
|
],
|
|
]
|
|
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/threshold.jsonl",
|
|
contents: env.jsonl([firstEntry, secondEntry]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
let report = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
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?.inputTokens == 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 `claude parses large lines with usage at tail`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 28)
|
|
let iso0 = env.isoString(for: day)
|
|
let largeText = String(repeating: "a", count: 70000)
|
|
|
|
let assistant: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"message": [
|
|
"model": "claude-sonnet-4-20250514",
|
|
"content": [
|
|
["type": "text", "text": largeText],
|
|
],
|
|
"usage": [
|
|
"input_tokens": 3714,
|
|
"output_tokens": 1,
|
|
],
|
|
],
|
|
]
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/large-line.jsonl",
|
|
contents: env.jsonl([assistant]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
let report = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
#expect(report.data.count == 1)
|
|
#expect(report.data[0].inputTokens == 3714)
|
|
#expect(report.data[0].outputTokens == 1)
|
|
#expect(report.data[0].totalTokens == 3715)
|
|
}
|
|
|
|
@Test
|
|
func `claude daily report refreshes when file changes`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
let model = "claude-sonnet-4-20250514"
|
|
let first: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"message": [
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 200,
|
|
"cache_creation_input_tokens": 50,
|
|
"cache_read_input_tokens": 25,
|
|
"output_tokens": 80,
|
|
],
|
|
],
|
|
]
|
|
let fileURL = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session-a.jsonl",
|
|
contents: env.jsonl([first]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
let firstReport = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
#expect(firstReport.data.first?.totalTokens == 355)
|
|
|
|
let second: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso1,
|
|
"message": [
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 40,
|
|
"cache_creation_input_tokens": 10,
|
|
"cache_read_input_tokens": 5,
|
|
"output_tokens": 20,
|
|
],
|
|
],
|
|
]
|
|
try env.jsonl([first, second]).write(to: fileURL, atomically: true, encoding: .utf8)
|
|
|
|
let secondReport = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
#expect(secondReport.data.first?.totalTokens == 430)
|
|
}
|
|
|
|
@Test
|
|
func `codex incremental parsing uses previous totals`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
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.2-codex"
|
|
let normalized = CostUsagePricing.normalizeCodexModel(model)
|
|
#expect(normalized == "gpt-5.2-codex")
|
|
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": [
|
|
"total_token_usage": [
|
|
"input_tokens": 100,
|
|
"cached_input_tokens": 20,
|
|
"output_tokens": 10,
|
|
],
|
|
"model": model,
|
|
],
|
|
],
|
|
]
|
|
|
|
let fileURL = try env.writeCodexSessionFile(
|
|
day: day,
|
|
filename: "session.jsonl",
|
|
contents: env.jsonl([turnContext, firstTokenCount]))
|
|
|
|
let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
|
|
let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range)
|
|
#expect(first.parsedBytes > 0)
|
|
#expect(first.lastTotals?.input == 100)
|
|
#expect(first.lastTotals?.cached == 20)
|
|
#expect(first.lastTotals?.output == 10)
|
|
|
|
let secondTokenCount: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso2,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"total_token_usage": [
|
|
"input_tokens": 160,
|
|
"cached_input_tokens": 40,
|
|
"output_tokens": 16,
|
|
],
|
|
"model": model,
|
|
],
|
|
],
|
|
]
|
|
try env.jsonl([turnContext, firstTokenCount, secondTokenCount])
|
|
.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
|
|
let delta = CostUsageScanner.parseCodexFile(
|
|
fileURL: fileURL,
|
|
range: range,
|
|
startOffset: first.parsedBytes,
|
|
initialModel: first.lastModel,
|
|
initialTotals: first.lastTotals)
|
|
let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day)
|
|
let packed = delta.days[dayKey]?[normalized] ?? []
|
|
#expect(packed.count >= 3)
|
|
#expect(packed[0] == 60)
|
|
#expect(packed[1] == 20)
|
|
#expect(packed[2] == 6)
|
|
}
|
|
|
|
@Test
|
|
func `codex incremental parsing keeps current turn id`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
let iso2 = env.isoString(for: day.addingTimeInterval(2))
|
|
let iso3 = env.isoString(for: day.addingTimeInterval(3))
|
|
|
|
let model = "openai/gpt-5.5"
|
|
let turnID = "22222222-2222-2222-2222-222222222222"
|
|
let turnContext: [String: Any] = [
|
|
"type": "turn_context",
|
|
"timestamp": iso0,
|
|
"payload": [
|
|
"model": model,
|
|
],
|
|
]
|
|
let taskStarted: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso1,
|
|
"payload": [
|
|
"type": "task_started",
|
|
"id": turnID,
|
|
],
|
|
]
|
|
let firstTokenCount: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso2,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"total_token_usage": [
|
|
"input_tokens": 100,
|
|
"cached_input_tokens": 20,
|
|
"output_tokens": 10,
|
|
],
|
|
],
|
|
],
|
|
]
|
|
|
|
let fileURL = try env.writeCodexSessionFile(
|
|
day: day,
|
|
filename: "priority-session.jsonl",
|
|
contents: env.jsonl([turnContext, taskStarted, firstTokenCount]))
|
|
let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
|
|
|
|
let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range)
|
|
#expect(first.lastCodexTurnID == turnID)
|
|
#expect(first.rows.map(\.turnID) == [turnID])
|
|
|
|
let secondTokenCount: [String: Any] = [
|
|
"type": "event_msg",
|
|
"timestamp": iso3,
|
|
"payload": [
|
|
"type": "token_count",
|
|
"info": [
|
|
"total_token_usage": [
|
|
"input_tokens": 160,
|
|
"cached_input_tokens": 40,
|
|
"output_tokens": 16,
|
|
],
|
|
],
|
|
],
|
|
]
|
|
try env.jsonl([turnContext, taskStarted, firstTokenCount, secondTokenCount])
|
|
.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
|
|
let delta = CostUsageScanner.parseCodexFile(
|
|
fileURL: fileURL,
|
|
range: range,
|
|
startOffset: first.parsedBytes,
|
|
initialModel: first.lastModel,
|
|
initialTotals: first.lastTotals,
|
|
initialCodexTurnID: first.lastCodexTurnID)
|
|
|
|
#expect(delta.lastCodexTurnID == turnID)
|
|
#expect(delta.rows.map(\.turnID) == [turnID])
|
|
#expect(delta.rows.first?.input == 60)
|
|
#expect(delta.rows.first?.cached == 20)
|
|
#expect(delta.rows.first?.output == 6)
|
|
}
|
|
|
|
@Test
|
|
func `codex fast parser does not trap on overflowing token integers`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
|
|
let iso = env.isoString(for: day)
|
|
let hugeInteger = String(repeating: "9", count: 100)
|
|
let line = """
|
|
{"type":"event_msg","timestamp":"\(
|
|
iso)","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":\(
|
|
hugeInteger),"cached_input_tokens":0,"output_tokens":5},"model":"openai/gpt-5.5"}}}
|
|
"""
|
|
let fileURL = try env.writeCodexSessionFile(day: day, filename: "overflow.jsonl", contents: line + "\n")
|
|
let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
|
|
|
|
let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range)
|
|
let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day)
|
|
let packed = parsed.days[dayKey]?["gpt-5.5"] ?? []
|
|
|
|
#expect(packed.count >= 3)
|
|
#expect(packed[0] == 0)
|
|
#expect(packed[1] == 0)
|
|
#expect(packed[2] == 5)
|
|
}
|
|
|
|
@Test
|
|
func `claude incremental parsing reads appended lines only`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
let model = "claude-sonnet-4-20250514"
|
|
let normalized = CostUsagePricing.normalizeClaudeModel(model)
|
|
let first: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"message": [
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 200,
|
|
"cache_creation_input_tokens": 50,
|
|
"cache_read_input_tokens": 25,
|
|
"output_tokens": 80,
|
|
],
|
|
],
|
|
]
|
|
let fileURL = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session-a.jsonl",
|
|
contents: env.jsonl([first]))
|
|
|
|
let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
|
|
let firstParse = CostUsageScanner.parseClaudeFile(
|
|
fileURL: fileURL,
|
|
range: range,
|
|
providerFilter: .all)
|
|
#expect(firstParse.parsedBytes > 0)
|
|
|
|
let second: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso1,
|
|
"message": [
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 40,
|
|
"cache_creation_input_tokens": 10,
|
|
"cache_read_input_tokens": 5,
|
|
"output_tokens": 20,
|
|
],
|
|
],
|
|
]
|
|
try env.jsonl([first, second]).write(to: fileURL, atomically: true, encoding: .utf8)
|
|
|
|
let delta = CostUsageScanner.parseClaudeFile(
|
|
fileURL: fileURL,
|
|
range: range,
|
|
providerFilter: .all,
|
|
startOffset: firstParse.parsedBytes)
|
|
let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day)
|
|
let packed = delta.days[dayKey]?[normalized] ?? []
|
|
#expect(packed.count >= 4)
|
|
#expect(packed[0] == 40)
|
|
#expect(packed[1] == 5)
|
|
#expect(packed[2] == 10)
|
|
#expect(packed[3] == 20)
|
|
}
|
|
|
|
@Test
|
|
func `day key from timestamp matches ISO parsing`() {
|
|
let timestamps = [
|
|
"2025-12-20T23:59:59Z",
|
|
"2025-12-20T23:59:59+02:00",
|
|
]
|
|
|
|
for ts in timestamps {
|
|
let expected = CostUsageScanner.dayKeyFromParsedISO(ts)
|
|
let fast = CostUsageScanner.dayKeyFromTimestamp(ts)
|
|
#expect(fast == expected)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `claude deduplicates streaming chunks`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
let iso2 = env.isoString(for: day.addingTimeInterval(2))
|
|
|
|
let model = "claude-sonnet-4-20250514"
|
|
let messageId = "msg_01ABC123"
|
|
let requestId = "req_01XYZ789"
|
|
|
|
// Streaming emits multiple chunks with same message.id + requestId.
|
|
// Each chunk has cumulative usage, not delta.
|
|
let chunk1: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"requestId": requestId,
|
|
"message": [
|
|
"id": messageId,
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 100,
|
|
"cache_creation_input_tokens": 50,
|
|
"cache_read_input_tokens": 25,
|
|
"output_tokens": 10,
|
|
],
|
|
],
|
|
]
|
|
let chunk2: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso1,
|
|
"requestId": requestId,
|
|
"message": [
|
|
"id": messageId,
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 100,
|
|
"cache_creation_input_tokens": 50,
|
|
"cache_read_input_tokens": 25,
|
|
"output_tokens": 10,
|
|
],
|
|
],
|
|
]
|
|
let chunk3: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso2,
|
|
"requestId": requestId,
|
|
"message": [
|
|
"id": messageId,
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 100,
|
|
"cache_creation_input_tokens": 50,
|
|
"cache_read_input_tokens": 25,
|
|
"output_tokens": 10,
|
|
],
|
|
],
|
|
]
|
|
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session-a.jsonl",
|
|
contents: env.jsonl([chunk1, chunk2, chunk3]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
let report = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
|
|
// Should only count once, not 3x.
|
|
#expect(report.data.count == 1)
|
|
#expect(report.data[0].inputTokens == 100)
|
|
#expect(report.data[0].cacheCreationTokens == 50)
|
|
#expect(report.data[0].cacheReadTokens == 25)
|
|
#expect(report.data[0].outputTokens == 10)
|
|
#expect(report.data[0].totalTokens == 185)
|
|
}
|
|
|
|
@Test
|
|
func `claude counts entries without ids as separate`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
let model = "claude-sonnet-4-20250514"
|
|
|
|
// Entries without message.id or requestId should still be counted
|
|
// (fallback for older log formats).
|
|
let entry1: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"message": [
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 100,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 50,
|
|
],
|
|
],
|
|
]
|
|
let entry2: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso1,
|
|
"message": [
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 200,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 100,
|
|
],
|
|
],
|
|
]
|
|
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session-a.jsonl",
|
|
contents: env.jsonl([entry1, entry2]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
let report = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
|
|
// Both entries should be counted since no IDs to dedupe.
|
|
#expect(report.data.count == 1)
|
|
#expect(report.data[0].inputTokens == 300)
|
|
#expect(report.data[0].outputTokens == 150)
|
|
#expect(report.data[0].totalTokens == 450)
|
|
}
|
|
|
|
@Test
|
|
func `claude counts different request ids separately`() throws {
|
|
let env = try CostUsageTestEnvironment()
|
|
defer { env.cleanup() }
|
|
|
|
let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
|
|
let iso0 = env.isoString(for: day)
|
|
let iso1 = env.isoString(for: day.addingTimeInterval(1))
|
|
|
|
let model = "claude-sonnet-4-20250514"
|
|
let messageId = "msg_01ABC123"
|
|
|
|
let entry1: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso0,
|
|
"requestId": "req_01AAA",
|
|
"message": [
|
|
"id": messageId,
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 10,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 5,
|
|
],
|
|
],
|
|
]
|
|
let entry2: [String: Any] = [
|
|
"type": "assistant",
|
|
"timestamp": iso1,
|
|
"requestId": "req_01BBB",
|
|
"message": [
|
|
"id": messageId,
|
|
"model": model,
|
|
"usage": [
|
|
"input_tokens": 20,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 10,
|
|
],
|
|
],
|
|
]
|
|
|
|
_ = try env.writeClaudeProjectFile(
|
|
relativePath: "project-a/session-a.jsonl",
|
|
contents: env.jsonl([entry1, entry2]))
|
|
|
|
var options = CostUsageScanner.Options(
|
|
codexSessionsRoot: nil,
|
|
claudeProjectsRoots: [env.claudeProjectsRoot],
|
|
cacheRoot: env.cacheRoot)
|
|
options.refreshMinIntervalSeconds = 0
|
|
|
|
let report = CostUsageScanner.loadDailyReport(
|
|
provider: .claude,
|
|
since: day,
|
|
until: day,
|
|
now: day,
|
|
options: options)
|
|
|
|
#expect(report.data.count == 1)
|
|
#expect(report.data[0].inputTokens == 30)
|
|
#expect(report.data[0].outputTokens == 15)
|
|
#expect(report.data[0].totalTokens == 45)
|
|
}
|
|
}
|
|
|
|
struct CostUsageTestEnvironment {
|
|
let root: URL
|
|
let cacheRoot: URL
|
|
let codexHomeRoot: URL
|
|
let codexSessionsRoot: URL
|
|
let codexArchivedSessionsRoot: URL
|
|
let claudeProjectsRoot: URL
|
|
let piSessionsRoot: URL
|
|
|
|
init() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
"codexbar-cost-usage-\(UUID().uuidString)",
|
|
isDirectory: true)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
self.root = root
|
|
self.cacheRoot = root.appendingPathComponent("cache", isDirectory: true)
|
|
self.codexHomeRoot = root.appendingPathComponent("codex-home", isDirectory: true)
|
|
self.codexSessionsRoot = self.codexHomeRoot.appendingPathComponent("sessions", isDirectory: true)
|
|
self.codexArchivedSessionsRoot = self.codexHomeRoot
|
|
.appendingPathComponent("archived_sessions", isDirectory: true)
|
|
self.claudeProjectsRoot = root.appendingPathComponent("claude-projects", isDirectory: true)
|
|
self.piSessionsRoot = root.appendingPathComponent("pi-sessions", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: self.cacheRoot, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(at: self.codexSessionsRoot, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(at: self.codexArchivedSessionsRoot, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(at: self.claudeProjectsRoot, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(at: self.piSessionsRoot, withIntermediateDirectories: true)
|
|
}
|
|
|
|
func cleanup() {
|
|
try? FileManager.default.removeItem(at: self.root)
|
|
}
|
|
|
|
func makeLocalNoon(year: Int, month: Int, day: Int) throws -> Date {
|
|
var comps = DateComponents()
|
|
comps.calendar = Calendar.current
|
|
comps.timeZone = TimeZone.current
|
|
comps.year = year
|
|
comps.month = month
|
|
comps.day = day
|
|
comps.hour = 12
|
|
comps.minute = 0
|
|
comps.second = 0
|
|
guard let date = comps.date else { throw NSError(domain: "CostUsageTestEnvironment", code: 1) }
|
|
return date
|
|
}
|
|
|
|
func isoString(for date: Date) -> String {
|
|
let fmt = ISO8601DateFormatter()
|
|
fmt.formatOptions = [.withInternetDateTime]
|
|
return fmt.string(from: date)
|
|
}
|
|
|
|
func writeCodexSessionFile(day: Date, filename: String, contents: String) throws -> URL {
|
|
let comps = Calendar.current.dateComponents([.year, .month, .day], from: day)
|
|
let y = String(format: "%04d", comps.year ?? 1970)
|
|
let m = String(format: "%02d", comps.month ?? 1)
|
|
let d = String(format: "%02d", comps.day ?? 1)
|
|
|
|
let dir = self.codexSessionsRoot
|
|
.appendingPathComponent(y, isDirectory: true)
|
|
.appendingPathComponent(m, isDirectory: true)
|
|
.appendingPathComponent(d, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
|
|
let url = dir.appendingPathComponent(filename, isDirectory: false)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func writeClaudeProjectFile(relativePath: String, contents: String) throws -> URL {
|
|
let url = self.claudeProjectsRoot.appendingPathComponent(relativePath, isDirectory: false)
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func writeClaudeDesktopLocalAgentFile(relativePath: String, contents: String) throws -> URL {
|
|
let localAgentRoot = self.root
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Application Support", isDirectory: true)
|
|
.appendingPathComponent("Claude", isDirectory: true)
|
|
.appendingPathComponent("local-agent-mode-sessions", isDirectory: true)
|
|
.appendingPathComponent("workspace-id", isDirectory: true)
|
|
.appendingPathComponent("session-id", isDirectory: true)
|
|
.appendingPathComponent("local_agent", isDirectory: true)
|
|
let url = localAgentRoot.appendingPathComponent(relativePath, isDirectory: false)
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func writeClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL {
|
|
try self.writeClaudeDesktopLocalAgentFile(
|
|
relativePath: ".claude/projects/\(relativePath)",
|
|
contents: contents)
|
|
}
|
|
|
|
func writeClaudeDesktopCodeSessionProjectFile(relativePath: String, contents: String) throws -> URL {
|
|
let projectsRoot = self.root
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Application Support", isDirectory: true)
|
|
.appendingPathComponent("Claude", isDirectory: true)
|
|
.appendingPathComponent("claude-code-sessions", isDirectory: true)
|
|
.appendingPathComponent("account-id", isDirectory: true)
|
|
.appendingPathComponent("org-id", isDirectory: true)
|
|
.appendingPathComponent(".claude", isDirectory: true)
|
|
.appendingPathComponent("projects", isDirectory: true)
|
|
let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func writeClaudeDesktopSharedProjectFile(relativePath: String, contents: String) throws -> URL {
|
|
let projectsRoot = self.root
|
|
.appendingPathComponent(".claude", isDirectory: true)
|
|
.appendingPathComponent("projects", isDirectory: true)
|
|
let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func writeNestedClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL {
|
|
let projectsRoot = self.root
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Application Support", isDirectory: true)
|
|
.appendingPathComponent("Claude", isDirectory: true)
|
|
.appendingPathComponent("local-agent-mode-sessions", isDirectory: true)
|
|
.appendingPathComponent("workspace-id", isDirectory: true)
|
|
.appendingPathComponent("session-id", isDirectory: true)
|
|
.appendingPathComponent("agent", isDirectory: true)
|
|
.appendingPathComponent("local_agent", isDirectory: true)
|
|
.appendingPathComponent(".claude", isDirectory: true)
|
|
.appendingPathComponent("projects", isDirectory: true)
|
|
let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func writeCodexArchivedSessionFile(filename: String, contents: String) throws -> URL {
|
|
let url = self.codexArchivedSessionsRoot.appendingPathComponent(filename, isDirectory: false)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func writePiSessionFile(relativePath: String, contents: String) throws -> URL {
|
|
let url = self.piSessionsRoot.appendingPathComponent(relativePath, isDirectory: false)
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
}
|
|
|
|
func jsonl(_ objects: [Any]) throws -> String {
|
|
let lines = try objects.map { obj in
|
|
let data = try JSONSerialization.data(withJSONObject: obj)
|
|
guard let text = String(bytes: data, encoding: .utf8) else {
|
|
throw NSError(domain: "CostUsageTestEnvironment", code: 2)
|
|
}
|
|
return text
|
|
}
|
|
return lines.joined(separator: "\n") + "\n"
|
|
}
|
|
}
|