From 368a487be639c9331a5e143f8572ad7b604d06e4 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 11 Aug 2026 13:34:06 +0800 Subject: [PATCH 1/4] fix: dedupe transcript lines sharing the same assistant message when computing token stats Claude Code transcripts can contain multiple JSONL lines carrying the same assistant response (retried/streamed writes sharing message.id and requestId), which was causing SessionParser and StatsCacheBuilder to double-count their token usage. --- .../Providers/Claude/Models/SessionMessage.cs | 14 ++++++++++++++ .../Providers/Claude/SessionParser.cs | 3 +++ .../Providers/Claude/StatsCacheBuilder.cs | 9 +++++++++ .../Claude/ClaudeUsageProviderTests.cs | 19 +++++++++++++++++++ .../Providers/Claude/SessionParserTests.cs | 17 +++++++++++++++++ 5 files changed, 62 insertions(+) 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/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/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/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() { From 89b483685b9898fc64dc0b64237fd23dbb48c262 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 11 Aug 2026 13:34:18 +0800 Subject: [PATCH 2/4] fix: dedupe duplicate transcript lines in hourly and recent activity builders HourlyActivityBuilder (watch hours view) and RecentActivityBuilder (watch mode's rolling window) summed assistant-message token usage straight from raw transcripts with no dedup, unlike StatsCacheBuilder and SessionParser which already guard against duplicate transcript lines for the same assistant response. This made watch mode's hourly totals diverge from the today/period summary tables. --- .../Providers/Claude/HourlyActivityBuilder.cs | 10 ++++ .../Providers/Claude/RecentActivityBuilder.cs | 6 +- .../Claude/HourlyActivityBuilderTests.cs | 57 +++++++++++++++++++ .../Claude/RecentActivityBuilderTests.cs | 44 ++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/AIUsageMonitor.Core.Tests/Providers/Claude/HourlyActivityBuilderTests.cs create mode 100644 tests/AIUsageMonitor.Core.Tests/Providers/Claude/RecentActivityBuilderTests.cs 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/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/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"]); + } +} From 95921b8a5e6e74653512634e13c8de7597663595 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 11 Aug 2026 13:34:30 +0800 Subject: [PATCH 3/4] fix: estimate daily cost using each model's real input/output/cache ratio Claude Code's stats-cache.json only stores a combined token total per model per day, so daily/period cost estimates were splitting that total using a fixed 25/25/50/0 input/output/cache guess regardless of the model's actual usage pattern. Use the model's observed cumulative input/output/cache-read/cache-creation ratio from ModelUsage instead, falling back to the old approximation only when that model has no recorded usage. --- .../Analytics/UsageAnalyzer.cs | 29 +++++++++++++--- .../Analytics/UsageAnalyzerTests.cs | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) 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/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() { From 673301bca9ad8106d4b758ea323fa63ede220a9a Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 11 Aug 2026 13:34:43 +0800 Subject: [PATCH 4/4] feat: include input/output/cache token breakdown in export model distribution ExportModelDistribution previously only exposed the combined total per model, so JSON/CSV exports couldn't distinguish input vs output vs cache token usage. --- src/AIUsageMonitor.Cli/Commands/ExportCommand.cs | 8 +++++--- src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) 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/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