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()
{