Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/AIUsageMonitor.Cli/Commands/ExportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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();
Expand Down
29 changes: 24 additions & 5 deletions src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -124,16 +124,35 @@ public SessionStats GetSessionStats(StatsCache cache)
}

/// <summary>
/// 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 (<paramref name="modelUsage"/>), since the daily
/// figures themselves only carry a combined total.
/// </summary>
/// <param name="tokensByModel">The total token counts recorded for each model.</param>
/// <param name="tokensByModel">The total token counts recorded for each model on the day being summarized.</param>
/// <param name="modelUsage">The cumulative input/output/cache token breakdown recorded for each model.</param>
/// <returns>The estimated total cost across all models.</returns>
private decimal EstimateDailyTokensCost(Dictionary<string, long> tokensByModel)
private decimal EstimateDailyTokensCost(Dictionary<string, long> tokensByModel, Dictionary<string, ModelUsageEntry> 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;
Expand Down
8 changes: 8 additions & 0 deletions src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,19 @@ public sealed record ExportPayload(
/// Represents the token usage distribution for a specific model within an export payload.
/// </summary>
/// <param name="ModelName">The name of the model.</param>
/// <param name="InputTokens">The number of input tokens consumed by the model.</param>
/// <param name="OutputTokens">The number of output tokens produced by the model.</param>
/// <param name="CacheReadTokens">The number of tokens read from cache.</param>
/// <param name="CacheCreationTokens">The number of tokens used to create cache entries.</param>
/// <param name="TotalTokens">The total number of tokens consumed by the model.</param>
/// <param name="Percentage">The percentage of overall token usage attributed to this model.</param>
/// <param name="EstimatedCost">The estimated monetary cost of usage for this model.</param>
public sealed record ExportModelDistribution(
string ModelName,
long InputTokens,
long OutputTokens,
long CacheReadTokens,
long CacheCreationTokens,
long TotalTokens,
double Percentage,
decimal EstimatedCost);
10 changes: 10 additions & 0 deletions src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand All @@ -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 +
Expand Down
14 changes: 14 additions & 0 deletions src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ public sealed class MessageContent
[JsonPropertyName("content")]
public System.Text.Json.JsonElement? Content { get; init; }

/// <summary>
/// Gets the API-assigned identifier of the message (e.g. <c>msg_...</c>), used to detect
/// duplicate transcript lines that carry the same usage.
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; init; }

/// <summary>
/// Gets the name of the model that generated the message.
/// </summary>
Expand Down Expand Up @@ -49,6 +56,13 @@ public sealed class SessionMessage
[JsonPropertyName("message")]
public MessageContent? Message { get; init; }

/// <summary>
/// Gets the API request identifier associated with this transcript line, used together with
/// <see cref="MessageContent.Id"/> to detect duplicate lines for the same assistant response.
/// </summary>
[JsonPropertyName("requestId")]
public string? RequestId { get; init; }

/// <summary>
/// Gets the identifier of the session this message belongs to.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ void ProcessFile(string file)
}

string? sessionId = null;
var seenAssistantMessageIds = new HashSet<(string?, string?)>();

foreach (var msg in parsed)
{
Expand Down Expand Up @@ -107,7 +108,10 @@ void ProcessFile(string file)
if (msg.Type == "assistant" && usage is not null)
{
var model = msg.Message?.Model ?? "unknown";
if (model != "<synthetic>")
var messageKey = (msg.Message!.Id, msg.RequestId) is (null, null)
? (msg.Uuid, (string?)null)
: (msg.Message!.Id, msg.RequestId);
if (model != "<synthetic>" && seenAssistantMessageIds.Add(messageKey))
{
var tokens = usage.InputTokens + usage.OutputTokens
+ usage.CacheReadInputTokens + usage.CacheCreationInputTokens;
Expand Down
3 changes: 3 additions & 0 deletions src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ public IEnumerable<SessionMessage> 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;
Expand Down
9 changes: 9 additions & 0 deletions src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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;

Expand Down
33 changes: 33 additions & 0 deletions tests/AIUsageMonitor.Core.Tests/Analytics/UsageAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<SessionParser>.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);
}
}
Original file line number Diff line number Diff line change
@@ -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<SessionParser>.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"]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading