diff --git a/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs b/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs index fa26aa6..28f9b6a 100644 --- a/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs @@ -46,7 +46,9 @@ public static Command Create(DataService dataService) cache.TotalMessages, cache.DailyActivity, cache.DailyModelTokens, - models.Select(m => new ExportModelDistribution(m.ModelName, m.TotalTokens, m.Percentage, m.EstimatedCost)).ToList()); + models.Select(m => new ExportModelDistribution( + m.ModelName, m.InputTokens, m.OutputTokens, m.CacheReadTokens, m.CacheCreationTokens, + m.TotalTokens, m.Percentage, m.EstimatedCost)).ToList()); content = JsonSerializer.Serialize(payload, CoreJsonContext.Default.ExportPayload); } @@ -84,10 +86,10 @@ private static string ExportCsv( } sb.AppendLine(); - sb.AppendLine("model,total_tokens,percentage,estimated_cost"); + sb.AppendLine("model,input_tokens,output_tokens,cache_read_tokens,cache_creation_tokens,total_tokens,percentage,estimated_cost"); foreach (var m in models) { - sb.AppendLine($"{m.ModelName},{m.TotalTokens},{m.Percentage:F1},{m.EstimatedCost:F2}"); + sb.AppendLine($"{m.ModelName},{m.InputTokens},{m.OutputTokens},{m.CacheReadTokens},{m.CacheCreationTokens},{m.TotalTokens},{m.Percentage:F1},{m.EstimatedCost:F2}"); } return sb.ToString(); diff --git a/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs b/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs index 0adf0dc..4b99681 100644 --- a/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs +++ b/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs @@ -31,7 +31,7 @@ public sealed class UsageAnalyzer(CostCalculator costCalculator) var tokensByModel = modelTokens?.TokensByModel ?? []; var totalTokens = tokensByModel.Values.Sum(); - var cost = EstimateDailyTokensCost(tokensByModel); + var cost = EstimateDailyTokensCost(tokensByModel, cache.ModelUsage); return new(date, activity.MessageCount, activity.SessionCount, activity.ToolCallCount, totalTokens, new(tokensByModel), cost); @@ -124,16 +124,35 @@ public SessionStats GetSessionStats(StatsCache cache) } /// - /// Estimates the cost of daily token usage per model by approximating the split between - /// input, output, and cache-related tokens. + /// Estimates the cost of daily token usage per model by splitting each day's combined + /// token total across input, output, and cache-related tokens using that model's + /// observed cumulative usage ratio (), since the daily + /// figures themselves only carry a combined total. /// - /// The total token counts recorded for each model. + /// The total token counts recorded for each model on the day being summarized. + /// The cumulative input/output/cache token breakdown recorded for each model. /// The estimated total cost across all models. - private decimal EstimateDailyTokensCost(Dictionary tokensByModel) + private decimal EstimateDailyTokensCost(Dictionary tokensByModel, Dictionary modelUsage) { var cost = 0m; foreach (var (model, tokens) in tokensByModel) { + if (modelUsage.TryGetValue(model, out var usage)) + { + var total = usage.InputTokens + usage.OutputTokens + + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; + if (total > 0) + { + var scale = (decimal)tokens / total; + cost += costCalculator.EstimateCost(model, + (long)(usage.InputTokens * scale), + (long)(usage.OutputTokens * scale), + (long)(usage.CacheReadInputTokens * scale), + (long)(usage.CacheCreationInputTokens * scale)); + continue; + } + } + cost += costCalculator.EstimateCost(model, tokens / 4, tokens / 4, tokens / 2, 0); } return cost; diff --git a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs index 480c439..acda7c7 100644 --- a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs +++ b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs @@ -162,11 +162,19 @@ public sealed record ExportPayload( /// Represents the token usage distribution for a specific model within an export payload. /// /// The name of the model. +/// The number of input tokens consumed by the model. +/// The number of output tokens produced by the model. +/// The number of tokens read from cache. +/// The number of tokens used to create cache entries. /// The total number of tokens consumed by the model. /// The percentage of overall token usage attributed to this model. /// The estimated monetary cost of usage for this model. public sealed record ExportModelDistribution( string ModelName, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens, long TotalTokens, double Percentage, decimal EstimatedCost); \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs index 829f560..0356a37 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs @@ -52,6 +52,8 @@ private void ProcessFile(string file, long[] tokensByHour) return; } + var seenAssistantMessageIds = new HashSet<(string?, string?)>(); + foreach (var msg in parsed) { if (msg.Type != "assistant" || @@ -73,6 +75,14 @@ msg.Timestamp is null || continue; } + var messageKey = (msg.Message!.Id, msg.RequestId) is (null, null) + ? (msg.Uuid, (string?)null) + : (msg.Message!.Id, msg.RequestId); + if (!seenAssistantMessageIds.Add(messageKey)) + { + continue; + } + var tokens = usage.InputTokens + usage.OutputTokens + diff --git a/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs b/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs index 8875990..26cb21a 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs @@ -13,6 +13,13 @@ public sealed class MessageContent [JsonPropertyName("content")] public System.Text.Json.JsonElement? Content { get; init; } + /// + /// Gets the API-assigned identifier of the message (e.g. msg_...), used to detect + /// duplicate transcript lines that carry the same usage. + /// + [JsonPropertyName("id")] + public string? Id { get; init; } + /// /// Gets the name of the model that generated the message. /// @@ -49,6 +56,13 @@ public sealed class SessionMessage [JsonPropertyName("message")] public MessageContent? Message { get; init; } + /// + /// Gets the API request identifier associated with this transcript line, used together with + /// to detect duplicate lines for the same assistant response. + /// + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + /// /// Gets the identifier of the session this message belongs to. /// diff --git a/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs index 9aea9e3..69652a2 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs @@ -79,6 +79,7 @@ void ProcessFile(string file) } string? sessionId = null; + var seenAssistantMessageIds = new HashSet<(string?, string?)>(); foreach (var msg in parsed) { @@ -107,7 +108,10 @@ void ProcessFile(string file) if (msg.Type == "assistant" && usage is not null) { var model = msg.Message?.Model ?? "unknown"; - if (model != "") + var messageKey = (msg.Message!.Id, msg.RequestId) is (null, null) + ? (msg.Uuid, (string?)null) + : (msg.Message!.Id, msg.RequestId); + if (model != "" && seenAssistantMessageIds.Add(messageKey)) { var tokens = usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs index 1d2c3dd..7fcd56d 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs @@ -76,6 +76,9 @@ public IEnumerable ParseFile(string filePath) var assistantMessages = messages .Where(m => m is { Type: "assistant", Message.Usage: not null }) + .DistinctBy(m => (m.Message!.Id, m.RequestId) is (null, null) + ? (object)m.Uuid! + : (m.Message!.Id, m.RequestId)) .ToList(); long totalTokens = 0; diff --git a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs index 971b067..c7eac01 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs @@ -66,6 +66,7 @@ void ProcessFile(string file) DateTimeOffset? sessionStart = null; DateTimeOffset? sessionEnd = null; + var seenAssistantMessageIds = new HashSet<(string?, string?)>(); foreach (var msg in messages) { @@ -108,6 +109,14 @@ void ProcessFile(string file) continue; } + var messageKey = (msg.Message!.Id, msg.RequestId) is (null, null) + ? (msg.Uuid, (string?)null) + : (msg.Message!.Id, msg.RequestId); + if (!seenAssistantMessageIds.Add(messageKey)) + { + continue; + } + var tokens = usage.InputTokens + usage.OutputTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; diff --git a/tests/AIUsageMonitor.Core.Tests/Analytics/UsageAnalyzerTests.cs b/tests/AIUsageMonitor.Core.Tests/Analytics/UsageAnalyzerTests.cs index 8cc53e9..277da50 100644 --- a/tests/AIUsageMonitor.Core.Tests/Analytics/UsageAnalyzerTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Analytics/UsageAnalyzerTests.cs @@ -23,6 +23,39 @@ public void GetDailySummary_AggregatesActivityAndTokens() Assert.Equal(1000, summary.TokensByModel["sonnet-5"]); } + [Fact] + public void GetDailySummary_EstimatesCost_UsingModelsRealInputOutputRatio() + { + var cache = BuildCache(); + + var summary = _sut.GetDailySummary(cache, new(2026, 8, 1)); + + Assert.NotNull(summary); + // sonnet-5 ModelUsage: input=300, output=100, cacheRead=50, cacheCreation=50 (total=500). + // Day total is 1000 tokens, so scale=2x that ratio: input=600, output=200, cacheRead=100, cacheCreation=100. + var costCalculator = new CostCalculator(); + var expected = costCalculator.EstimateCost("sonnet-5", 600, 200, 100, 100); + Assert.Equal(expected, summary.EstimatedCost); + Assert.NotEqual(costCalculator.EstimateCost("sonnet-5", 250, 250, 500, 0), summary.EstimatedCost); + } + + [Fact] + public void GetDailySummary_ModelMissingFromModelUsage_FallsBackToApproximateSplit() + { + var cache = new StatsCache + { + DailyActivity = [new() { Date = new(2026, 8, 1), MessageCount = 1, SessionCount = 1, ToolCallCount = 0 }], + DailyModelTokens = [new() { Date = new(2026, 8, 1), TokensByModel = new() { ["unknown-model"] = 1000 } }], + ModelUsage = [], + }; + + var summary = _sut.GetDailySummary(cache, new(2026, 8, 1)); + + Assert.NotNull(summary); + var expected = new CostCalculator().EstimateCost("unknown-model", 250, 250, 500, 0); + Assert.Equal(expected, summary.EstimatedCost); + } + [Fact] public void GetDailySummary_NoModelTokens_TotalTokensIsZero() { diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs index 23edec2..dfc09ef 100644 --- a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs @@ -100,4 +100,23 @@ public void GetStatsCache_StatsCacheNewerThanSessions_UsesStatsCacheAsIs() Assert.True(cache.ModelUsage.ContainsKey("cached-model")); } + + [Fact] + public void GetStatsCache_DuplicateMessageIdInTranscript_CountsTokensOnce() + { + var sessionFile = Path.Combine(_claudeDir, "projects", "session.jsonl"); + var t1 = DateTimeOffset.UtcNow.ToString("O"); + var t2 = DateTimeOffset.UtcNow.AddSeconds(5).ToString("O"); + File.WriteAllLines(sessionFile, + [ + "{\"type\":\"user\",\"timestamp\":\"" + t1 + "\",\"sessionId\":\"s1\"}", + "{\"type\":\"assistant\",\"timestamp\":\"" + t1 + "\",\"sessionId\":\"s1\",\"requestId\":\"req1\",\"message\":{\"id\":\"msg1\",\"role\":\"assistant\",\"model\":\"fresh-model\",\"usage\":{\"input_tokens\":100,\"output_tokens\":50}}}", + "{\"type\":\"assistant\",\"timestamp\":\"" + t2 + "\",\"sessionId\":\"s1\",\"requestId\":\"req1\",\"message\":{\"id\":\"msg1\",\"role\":\"assistant\",\"model\":\"fresh-model\",\"usage\":{\"input_tokens\":100,\"output_tokens\":50}}}", + ]); + File.SetLastWriteTimeUtc(sessionFile, DateTime.UtcNow); + + var cache = _sut.GetStatsCache(); + + Assert.Equal(150, cache.ModelUsage["fresh-model"].InputTokens + cache.ModelUsage["fresh-model"].OutputTokens); + } } diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/HourlyActivityBuilderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/HourlyActivityBuilderTests.cs new file mode 100644 index 0000000..3bdca8e --- /dev/null +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/HourlyActivityBuilderTests.cs @@ -0,0 +1,57 @@ +using AIUsageMonitor.Core.Providers.Claude; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AIUsageMonitor.Core.Tests.Providers.Claude; + +public class HourlyActivityBuilderTests : IDisposable +{ + private readonly SessionParser _sessionParser = new(NullLogger.Instance); + private readonly HourlyActivityBuilder _sut; + private readonly string _tempFile = Path.Combine(Path.GetTempPath(), $"session-{Guid.NewGuid()}.jsonl"); + + public HourlyActivityBuilderTests() + { + _sut = new HourlyActivityBuilder(new SessionFileCache(_sessionParser)); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + } + + [Fact] + public void Build_DuplicateMessageId_CountsTokensOnce() + { + File.WriteAllLines(_tempFile, + [ + """{"type":"assistant","timestamp":"2026-08-01T09:00:00Z","sessionId":"s1","requestId":"req1","message":{"id":"msg1","role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":0}}}""", + """{"type":"assistant","timestamp":"2026-08-01T09:00:05Z","sessionId":"s1","requestId":"req1","message":{"id":"msg1","role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":0}}}""", + ]); + + var result = _sut.Build([_tempFile]); + + var hour = result.Single(h => h.Hour == new DateTimeOffset(2026, 8, 1, 9, 0, 0, TimeSpan.Zero).LocalDateTime.Hour); + Assert.Equal(160, hour.TotalTokens); + } + + [Fact] + public void Build_DistinctMessages_SumsTokensAcrossHours() + { + File.WriteAllLines(_tempFile, + [ + """{"type":"assistant","timestamp":"2026-08-01T09:00:00Z","sessionId":"s1","requestId":"req1","message":{"id":"msg1","role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}""", + """{"type":"assistant","timestamp":"2026-08-01T10:00:00Z","sessionId":"s1","requestId":"req2","message":{"id":"msg2","role":"assistant","model":"sonnet-5","usage":{"input_tokens":20,"output_tokens":10,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}""", + ]); + + var result = _sut.Build([_tempFile]); + + var hour9 = new DateTimeOffset(2026, 8, 1, 9, 0, 0, TimeSpan.Zero).LocalDateTime.Hour; + var hour10 = new DateTimeOffset(2026, 8, 1, 10, 0, 0, TimeSpan.Zero).LocalDateTime.Hour; + Assert.Equal(150, result.Single(h => h.Hour == hour9).TotalTokens); + Assert.Equal(30, result.Single(h => h.Hour == hour10).TotalTokens); + } +} diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/RecentActivityBuilderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/RecentActivityBuilderTests.cs new file mode 100644 index 0000000..7d937a2 --- /dev/null +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/RecentActivityBuilderTests.cs @@ -0,0 +1,44 @@ +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Providers.Claude; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AIUsageMonitor.Core.Tests.Providers.Claude; + +public class RecentActivityBuilderTests : IDisposable +{ + private readonly SessionParser _sessionParser = new(NullLogger.Instance); + private readonly RecentActivityBuilder _sut; + private readonly string _tempFile = Path.Combine(Path.GetTempPath(), $"session-{Guid.NewGuid()}.jsonl"); + + public RecentActivityBuilderTests() + { + _sut = new RecentActivityBuilder(new SessionFileCache(_sessionParser), new CostCalculator()); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + } + + [Fact] + public void Build_DuplicateMessageId_CountsTokensOnce() + { + var now = DateTimeOffset.Now; + var recent = now.AddMinutes(-5); + var timestamp = recent.ToString("O"); + + var line = "{\"type\":\"assistant\",\"timestamp\":\"" + timestamp + + "\",\"sessionId\":\"s1\",\"requestId\":\"req1\",\"message\":{\"id\":\"msg1\",\"role\":\"assistant\",\"model\":\"sonnet-5\",\"usage\":{\"input_tokens\":100,\"output_tokens\":50,\"cache_read_input_tokens\":10,\"cache_creation_input_tokens\":0}}}"; + + File.WriteAllLines(_tempFile, [line, line]); + + var result = _sut.Build([_tempFile], TimeSpan.FromHours(1)); + + Assert.Equal(160, result.TotalTokens); + Assert.Equal(160, result.TokensByModel["sonnet-5"]); + } +} diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs index 9f7a7f2..8db86ea 100644 --- a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs @@ -53,6 +53,23 @@ public void ParseSessionSummary_ComputesDurationAndTokensByModel() Assert.Equal(2, summary.MessageCount); } + [Fact] + public void ParseSessionSummary_DuplicateMessageId_CountsTokensOnce() + { + File.WriteAllLines(_tempFile, + [ + """{"type":"user","timestamp":"2026-08-01T00:00:00Z","sessionId":"s1"}""", + """{"type":"assistant","timestamp":"2026-08-01T00:01:00Z","sessionId":"s1","requestId":"req1","message":{"id":"msg1","role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":0}}}""", + """{"type":"assistant","timestamp":"2026-08-01T00:01:05Z","sessionId":"s1","requestId":"req1","message":{"id":"msg1","role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":0}}}""", + ]); + + var summary = _sut.ParseSessionSummary(_tempFile); + + Assert.NotNull(summary); + Assert.Equal(160, summary.TotalTokens); + Assert.Equal(160, summary.TokensByModel["sonnet-5"]); + } + [Fact] public void ParseSessionSummary_EmptyFile_ReturnsNull() {