feat(usage): aggregate TokenTracker sessions by model and per task
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||||||
|
|
||||||
|
public sealed record TokenTrackerTotals(long TotalTokens, double CostUsd, int Sessions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure aggregation over a parsed export. One fetch covers the whole history — <c>--from</c> and
|
||||||
|
/// <c>--to</c> are accepted by the CLI but do <b>not</b> filter its output (measured against
|
||||||
|
/// v0.88.4: identical 886 rows with and without a range) — so filtering here is load-bearing, and
|
||||||
|
/// switching the range in the UI costs nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static class TokenTrackerAggregator
|
||||||
|
{
|
||||||
|
public const string ScopeClaudeDo = "claudedo";
|
||||||
|
public const string ScopeOther = "other";
|
||||||
|
|
||||||
|
public static IReadOnlyList<TokenTrackerModelRow> ByModel(
|
||||||
|
IReadOnlyList<TokenTrackerSession> sessions,
|
||||||
|
DateOnly from,
|
||||||
|
DateOnly to,
|
||||||
|
ISet<string> claudeDoHashes)
|
||||||
|
{
|
||||||
|
var buckets = new Dictionary<(DateOnly Date, string Model, string Scope), Accumulator>();
|
||||||
|
|
||||||
|
foreach (var session in InRange(sessions, from, to))
|
||||||
|
{
|
||||||
|
var key = (
|
||||||
|
DateOf(session),
|
||||||
|
session.Model,
|
||||||
|
claudeDoHashes.Contains(session.SessionHash) ? ScopeClaudeDo : ScopeOther);
|
||||||
|
|
||||||
|
if (!buckets.TryGetValue(key, out var acc))
|
||||||
|
{
|
||||||
|
acc = new Accumulator();
|
||||||
|
buckets[key] = acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.Input += session.InputTokens;
|
||||||
|
acc.Output += session.OutputTokens;
|
||||||
|
acc.CacheRead += session.CacheReadTokens;
|
||||||
|
acc.CacheCreation += session.CacheCreationTokens;
|
||||||
|
acc.Cost += session.CostUsd;
|
||||||
|
acc.Sessions++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
.Select(kv => new TokenTrackerModelRow(
|
||||||
|
kv.Key.Date, kv.Key.Model, kv.Key.Scope,
|
||||||
|
kv.Value.Input, kv.Value.Output, kv.Value.CacheRead, kv.Value.CacheCreation,
|
||||||
|
kv.Value.Sessions, kv.Value.Cost))
|
||||||
|
.OrderBy(r => r.Date).ThenBy(r => r.Model).ThenBy(r => r.Scope)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cost and efficiency for one task, given the session ids of its runs. Null when
|
||||||
|
/// none of them appear in the export — the caller then shows tokens without cost. Several
|
||||||
|
/// export rows can share one session hash (they are disjoint segments of the same session),
|
||||||
|
/// so summing every match is correct rather than double-counting.</summary>
|
||||||
|
public static TokenTrackerTaskExtras? ForSessions(
|
||||||
|
IReadOnlyList<TokenTrackerSession> sessions,
|
||||||
|
IEnumerable<string?> sessionIds)
|
||||||
|
{
|
||||||
|
var wanted = sessionIds
|
||||||
|
.Select(SessionHash.ForClaudeSession)
|
||||||
|
.Where(h => h is not null)
|
||||||
|
.ToHashSet()!;
|
||||||
|
|
||||||
|
if (wanted.Count == 0) return null;
|
||||||
|
|
||||||
|
var matched = sessions.Where(s => wanted.Contains(s.SessionHash)).ToList();
|
||||||
|
if (matched.Count == 0) return null;
|
||||||
|
|
||||||
|
return new TokenTrackerTaskExtras(
|
||||||
|
CostUsd: matched.Sum(s => s.CostUsd),
|
||||||
|
Retries: matched.Sum(s => s.RetryTurns),
|
||||||
|
Productive: matched.Any(s => s.Productive),
|
||||||
|
OneShot: matched.All(s => s.OneShot));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TokenTrackerTotals Totals(
|
||||||
|
IReadOnlyList<TokenTrackerSession> sessions, DateOnly from, DateOnly to)
|
||||||
|
{
|
||||||
|
var inRange = InRange(sessions, from, to).ToList();
|
||||||
|
return new TokenTrackerTotals(
|
||||||
|
TotalTokens: inRange.Sum(s =>
|
||||||
|
s.InputTokens + s.OutputTokens + s.CacheReadTokens + s.CacheCreationTokens),
|
||||||
|
CostUsd: inRange.Sum(s => s.CostUsd),
|
||||||
|
Sessions: inRange.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<TokenTrackerSession> InRange(
|
||||||
|
IReadOnlyList<TokenTrackerSession> sessions, DateOnly from, DateOnly to) =>
|
||||||
|
sessions.Where(s => DateOf(s) >= from && DateOf(s) <= to);
|
||||||
|
|
||||||
|
/// <summary>Local start date — the UI's pickers and the old transcript reader both work in
|
||||||
|
/// local time, so a UTC-based date would shift rows across midnight for the user.</summary>
|
||||||
|
private static DateOnly DateOf(TokenTrackerSession session) =>
|
||||||
|
DateOnly.FromDateTime(session.StartedAt.LocalDateTime);
|
||||||
|
|
||||||
|
private sealed class Accumulator
|
||||||
|
{
|
||||||
|
public long Input, Output, CacheRead, CacheCreation;
|
||||||
|
public double Cost;
|
||||||
|
public int Sessions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using ClaudeDo.Worker.Usage.TokenTracker;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||||||
|
|
||||||
|
public sealed class TokenTrackerAggregatorTests
|
||||||
|
{
|
||||||
|
private static readonly DateOnly RangeStart = new(2026, 8, 20);
|
||||||
|
private static readonly DateOnly RangeEnd = new(2026, 8, 21);
|
||||||
|
|
||||||
|
private static IReadOnlyList<TokenTrackerSession> Sessions() =>
|
||||||
|
TokenTrackerExportParser.Parse(
|
||||||
|
TokenTrackerFixtures.SessionsJson,
|
||||||
|
new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc))!.Sessions;
|
||||||
|
|
||||||
|
private static HashSet<string> ClaudeDoHashes() =>
|
||||||
|
[
|
||||||
|
SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdA)!,
|
||||||
|
SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdB)!,
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ByModel_ExcludesSessionsOutsideTheRange()
|
||||||
|
{
|
||||||
|
var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes());
|
||||||
|
|
||||||
|
Assert.All(rows, r => Assert.InRange(r.Date, RangeStart, RangeEnd));
|
||||||
|
Assert.DoesNotContain(rows, r => r.CostUsd == 99.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ByModel_SplitsClaudeDoFromOther()
|
||||||
|
{
|
||||||
|
var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes());
|
||||||
|
|
||||||
|
var opus = rows.Single(r => r.Model == "claude-opus-5" && r.Scope == "claudedo");
|
||||||
|
Assert.Equal(110, opus.InputTokens);
|
||||||
|
Assert.Equal(440, opus.OutputTokens);
|
||||||
|
Assert.Equal(990, opus.CacheReadTokens);
|
||||||
|
Assert.Equal(55, opus.CacheCreationTokens);
|
||||||
|
Assert.Equal(2, opus.Sessions);
|
||||||
|
Assert.Equal(3.5, opus.CostUsd, 6);
|
||||||
|
|
||||||
|
var sonnet = rows.Single(r => r.Model == "claude-sonnet-5");
|
||||||
|
Assert.Equal("other", sonnet.Scope);
|
||||||
|
Assert.Equal(0.25, sonnet.CostUsd, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ByModel_UnknownHash_CountsAsOther()
|
||||||
|
{
|
||||||
|
var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, new HashSet<string>());
|
||||||
|
|
||||||
|
Assert.All(rows, r => Assert.Equal("other", r.Scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ByModel_EmptyInput_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(TokenTrackerAggregator.ByModel([], RangeStart, RangeEnd, new HashSet<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForSessions_SumsCostAndRetriesOfMatchingRows()
|
||||||
|
{
|
||||||
|
var extras = TokenTrackerAggregator.ForSessions(
|
||||||
|
Sessions(),
|
||||||
|
[TokenTrackerFixtures.ClaudeDoSessionIdA, TokenTrackerFixtures.ClaudeDoSessionIdB]);
|
||||||
|
|
||||||
|
Assert.NotNull(extras);
|
||||||
|
Assert.Equal(3.5, extras!.CostUsd, 6);
|
||||||
|
Assert.Equal(3, extras.Retries);
|
||||||
|
Assert.True(extras.Productive); // A is productive
|
||||||
|
Assert.False(extras.OneShot); // B is not one-shot
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForSessions_SingleOneShotSession_ReportsOneShot()
|
||||||
|
{
|
||||||
|
var extras = TokenTrackerAggregator.ForSessions(
|
||||||
|
Sessions(), [TokenTrackerFixtures.ClaudeDoSessionIdA]);
|
||||||
|
|
||||||
|
Assert.True(extras!.OneShot);
|
||||||
|
Assert.Equal(2.5, extras.CostUsd, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForSessions_NoMatch_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), ["11111111-2222-3333-4444-555555555555"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForSessions_BlankIds_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), [null, "", " "]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Totals_SumsTokensAndCostInRange()
|
||||||
|
{
|
||||||
|
var totals = TokenTrackerAggregator.Totals(Sessions(), RangeStart, RangeEnd);
|
||||||
|
|
||||||
|
Assert.Equal(1615, totals.TotalTokens); // 1450 + 145 + 20
|
||||||
|
Assert.Equal(3.75, totals.CostUsd, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user